skeleton complete
This commit is contained in:
@@ -3,20 +3,24 @@
|
|||||||
source "$(dirname "$0")/config.sh"
|
source "$(dirname "$0")/config.sh"
|
||||||
|
|
||||||
BUN_PARALLEL_COMPACT \
|
BUN_PARALLEL_COMPACT \
|
||||||
'server/tests/attach/basic' \
|
'server/tests/billing/subscription-update' \
|
||||||
'server/tests/attach/upgrade' \
|
|
||||||
'server/tests/attach/downgrade' \
|
|
||||||
'server/tests/attach/free' \
|
|
||||||
'server/tests/attach/addOn' \
|
|
||||||
'server/tests/attach/checkout' \
|
|
||||||
'server/tests/attach/misc' \
|
|
||||||
'server/tests/billing/invoice-action-required' \
|
|
||||||
'server/tests/billing/cancel' \
|
|
||||||
'server/tests/billing/cancel/add-ons' \
|
|
||||||
'server/tests/renew' \
|
|
||||||
--max=6
|
--max=6
|
||||||
|
|
||||||
BUN_PARALLEL_COMPACT \
|
# BUN_PARALLEL_COMPACT \
|
||||||
'server/tests/attach/entities' \
|
# 'server/tests/attach/basic' \
|
||||||
--max=6
|
# 'server/tests/attach/upgrade' \
|
||||||
# 'server/tests/external-psps/revenuecat' \
|
# 'server/tests/attach/downgrade' \
|
||||||
|
# 'server/tests/attach/free' \
|
||||||
|
# 'server/tests/attach/addOn' \
|
||||||
|
# 'server/tests/attach/checkout' \
|
||||||
|
# 'server/tests/attach/misc' \
|
||||||
|
# 'server/tests/billing/invoice-action-required' \
|
||||||
|
# 'server/tests/billing/cancel' \
|
||||||
|
# 'server/tests/billing/cancel/add-ons' \
|
||||||
|
# 'server/tests/renew' \
|
||||||
|
# --max=6
|
||||||
|
|
||||||
|
# BUN_PARALLEL_COMPACT \
|
||||||
|
# 'server/tests/attach/entities' \
|
||||||
|
# --max=6
|
||||||
|
# # 'server/tests/external-psps/revenuecat' \
|
||||||
|
|||||||
4
server/src/external/autumn/autumnCli.ts
vendored
4
server/src/external/autumn/autumnCli.ts
vendored
@@ -17,8 +17,8 @@ import {
|
|||||||
type LegacyVersion,
|
type LegacyVersion,
|
||||||
type OrgConfig,
|
type OrgConfig,
|
||||||
type RewardRedemption,
|
type RewardRedemption,
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
type TrackParams,
|
type TrackParams,
|
||||||
|
type UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { defaultApiVersion } from "@tests/constants.js";
|
import { defaultApiVersion } from "@tests/constants.js";
|
||||||
import type {
|
import type {
|
||||||
@@ -683,7 +683,7 @@ export class AutumnInt {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
subscriptionUpdate = async (params: SubscriptionUpdateV0Params) => {
|
subscriptionUpdate = async (params: UpdateSubscriptionV0Params) => {
|
||||||
const data = await this.post(`/subscriptions/update`, params);
|
const data = await this.post(`/subscriptions/update`, params);
|
||||||
return data;
|
return data;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "@autumn/shared";
|
import type { Metadata } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
import { executeAutumnBillingPlan } from "@/internal/billing/v2/execute/executeAutumnBillingPlan";
|
||||||
|
import { executeStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/execute/executeStripeBillingPlan";
|
||||||
import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan";
|
import type { DeferredAutumnBillingPlanData } from "@/internal/billing/v2/types/billingPlan";
|
||||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
import { MetadataService } from "@/internal/metadata/MetadataService";
|
||||||
|
|
||||||
@@ -19,9 +20,20 @@ export const handleDeferredAutumnBillingPlan = async ({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { billingPlan, billingContext } = data;
|
||||||
|
|
||||||
|
// Execute stripe billing plan
|
||||||
|
await executeStripeBillingPlan({
|
||||||
|
ctx,
|
||||||
|
stripeBillingPlan: billingPlan.stripe,
|
||||||
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
|
billingContext,
|
||||||
|
resumeFromDeferred: true,
|
||||||
|
});
|
||||||
|
|
||||||
await executeAutumnBillingPlan({
|
await executeAutumnBillingPlan({
|
||||||
ctx,
|
ctx,
|
||||||
autumnBillingPlan: data.autumnBillingPlan,
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
});
|
});
|
||||||
|
|
||||||
await MetadataService.delete({ db, id: metadata.id });
|
await MetadataService.delete({ db, id: metadata.id });
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ export const handleInvoicePaidMetadata = async ({
|
|||||||
if (!metadata) return;
|
if (!metadata) return;
|
||||||
|
|
||||||
// Handle deferred billing plan (v2 flow)
|
// Handle deferred billing plan (v2 flow)
|
||||||
if (metadata.type === MetadataType.DeferredAutumnBillingPlan) {
|
if (
|
||||||
|
metadata.type === MetadataType.InvoiceCheckoutV2 ||
|
||||||
|
metadata.type === MetadataType.InvoiceActionRequiredV2
|
||||||
|
) {
|
||||||
await handleDeferredAutumnBillingPlan({ ctx, metadata });
|
await handleDeferredAutumnBillingPlan({ ctx, metadata });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ const coreUrls = [
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
url: "/cancel",
|
url: "/cancel",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
url: "/subscriptions/update",
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { handleSubscriptionUpdatePreview } from "@/internal/billing/v2/subscriptionUpdate/handleSubscriptionUpdatePreview.js";
|
import { handleUpdateSubscriptionPreview } from "@/internal/billing/v2/subscriptionUpdate/handleUpdateSubscriptionPreview.js";
|
||||||
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
import type { HonoEnv } from "../../honoUtils/HonoEnv.js";
|
||||||
import { handleAttach } from "./attach/handleAttach.js";
|
import { handleAttach } from "./attach/handleAttach.js";
|
||||||
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
import { handleCheckoutV2 } from "./checkout/handleCheckoutV2.js";
|
||||||
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
|
import { handleSetupPayment } from "./handlers/handleSetupPayment.js";
|
||||||
import { handleApiSubscriptionUpdate } from "./v2/handlers/handleApiSubscriptionUpdate.js";
|
|
||||||
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
|
import { handleAttachV2 } from "./v2/handlers/handleAttachV2.js";
|
||||||
|
import { handleUpdateSubscription } from "./v2/handlers/handleUpdateSubscription.js";
|
||||||
|
|
||||||
export const billingRouter = new Hono<HonoEnv>();
|
export const billingRouter = new Hono<HonoEnv>();
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ billingRouter.post("/checkout", ...handleCheckoutV2);
|
|||||||
billingRouter.post("/attach", ...handleAttach);
|
billingRouter.post("/attach", ...handleAttach);
|
||||||
billingRouter.post("/attach_v2", ...handleAttachV2);
|
billingRouter.post("/attach_v2", ...handleAttachV2);
|
||||||
|
|
||||||
billingRouter.post("/subscriptions/update", ...handleApiSubscriptionUpdate);
|
billingRouter.post("/subscriptions/update", ...handleUpdateSubscription);
|
||||||
billingRouter.post(
|
billingRouter.post(
|
||||||
"/subscriptions/preview/update",
|
"/subscriptions/preview/update",
|
||||||
...handleSubscriptionUpdatePreview,
|
...handleUpdateSubscriptionPreview,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { FullProduct } from "@autumn/shared";
|
import type { FullCusProduct, FullProduct } from "@autumn/shared";
|
||||||
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
|
import type { FullCustomer } from "@shared/models/cusModels/fullCusModel";
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const InvoiceModeSchema = z.object({
|
||||||
|
finalizeInvoice: z.boolean().default(false),
|
||||||
|
enableProductImmediately: z.boolean().default(true),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
|
||||||
|
|
||||||
export interface BillingContext {
|
export interface BillingContext {
|
||||||
fullCustomer: FullCustomer;
|
fullCustomer: FullCustomer;
|
||||||
@@ -10,5 +18,17 @@ export interface BillingContext {
|
|||||||
stripeSubscription?: Stripe.Subscription;
|
stripeSubscription?: Stripe.Subscription;
|
||||||
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
|
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
|
||||||
paymentMethod?: Stripe.PaymentMethod;
|
paymentMethod?: Stripe.PaymentMethod;
|
||||||
testClockFrozenTime?: number;
|
|
||||||
|
// Timestamps...
|
||||||
|
currentEpochMs: number;
|
||||||
|
billingCycleAnchorMs?: number;
|
||||||
|
|
||||||
|
// Invoice mode
|
||||||
|
invoiceMode?: InvoiceMode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdateSubscriptionBillingContext extends BillingContext {
|
||||||
|
customerProduct: FullCusProduct; // target customer product
|
||||||
|
}
|
||||||
|
|
||||||
|
// testClockFrozenTime?: number;
|
||||||
|
|||||||
@@ -3,30 +3,29 @@ import {
|
|||||||
cusProductToLineItems,
|
cusProductToLineItems,
|
||||||
type FullCusProduct,
|
type FullCusProduct,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
import type { AutumnContext } from "../../../../../honoUtils/HonoEnv";
|
||||||
|
|
||||||
export const buildAutumnLineItems = ({
|
export const buildAutumnLineItems = ({
|
||||||
ctx,
|
ctx,
|
||||||
newCusProducts,
|
newCustomerProducts,
|
||||||
ongoingCustomerProduct,
|
deletedCustomerProduct,
|
||||||
billingCycleAnchor,
|
billingContext,
|
||||||
testClockFrozenTime,
|
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
newCusProducts: FullCusProduct[];
|
newCustomerProducts: FullCusProduct[];
|
||||||
ongoingCustomerProduct?: FullCusProduct;
|
deletedCustomerProduct?: FullCusProduct;
|
||||||
billingCycleAnchor?: number;
|
billingContext: BillingContext;
|
||||||
testClockFrozenTime?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
const now = testClockFrozenTime ?? Date.now();
|
// billingCycleAnchor = billingCycleAnchor ?? now;
|
||||||
billingCycleAnchor = billingCycleAnchor ?? now;
|
const billingCycleAnchor = billingContext.billingCycleAnchorMs;
|
||||||
|
const now = billingContext.currentEpochMs;
|
||||||
|
|
||||||
const { org } = ctx;
|
const { org } = ctx;
|
||||||
|
|
||||||
const arrearLineItems = ongoingCustomerProduct
|
const arrearLineItems = deletedCustomerProduct
|
||||||
? cusProductToArrearLineItems({
|
? cusProductToArrearLineItems({
|
||||||
cusProduct: ongoingCustomerProduct,
|
cusProduct: deletedCustomerProduct,
|
||||||
billingCycleAnchor: billingCycleAnchor!,
|
billingCycleAnchor: billingCycleAnchor!,
|
||||||
now,
|
now,
|
||||||
org,
|
org,
|
||||||
@@ -34,9 +33,9 @@ export const buildAutumnLineItems = ({
|
|||||||
: [];
|
: [];
|
||||||
|
|
||||||
// Get line items for ongoing cus product
|
// Get line items for ongoing cus product
|
||||||
const ongoingLineItems = ongoingCustomerProduct
|
const deletedLineItems = deletedCustomerProduct
|
||||||
? cusProductToLineItems({
|
? cusProductToLineItems({
|
||||||
cusProduct: ongoingCustomerProduct,
|
cusProduct: deletedCustomerProduct,
|
||||||
now,
|
now,
|
||||||
billingCycleAnchor: billingCycleAnchor!,
|
billingCycleAnchor: billingCycleAnchor!,
|
||||||
direction: "refund",
|
direction: "refund",
|
||||||
@@ -44,9 +43,9 @@ export const buildAutumnLineItems = ({
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const newLineItems = newCusProducts.flatMap((newCusProduct) =>
|
const newLineItems = newCustomerProducts.flatMap((newCustomerProduct) =>
|
||||||
cusProductToLineItems({
|
cusProductToLineItems({
|
||||||
cusProduct: newCusProduct,
|
cusProduct: newCustomerProduct,
|
||||||
now,
|
now,
|
||||||
billingCycleAnchor: billingCycleAnchor!,
|
billingCycleAnchor: billingCycleAnchor!,
|
||||||
direction: "charge",
|
direction: "charge",
|
||||||
@@ -56,7 +55,7 @@ export const buildAutumnLineItems = ({
|
|||||||
|
|
||||||
// All items
|
// All items
|
||||||
const allLineItems = [
|
const allLineItems = [
|
||||||
...ongoingLineItems,
|
...deletedLineItems,
|
||||||
...arrearLineItems,
|
...arrearLineItems,
|
||||||
...newLineItems,
|
...newLineItems,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type {
|
|||||||
Feature,
|
Feature,
|
||||||
FeatureOptions,
|
FeatureOptions,
|
||||||
Price,
|
Price,
|
||||||
SubscriptionUpdateV0Params,
|
UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { roundUsageToNearestBillingUnit } from "@autumn/shared";
|
import { roundUsageToNearestBillingUnit } from "@autumn/shared";
|
||||||
import { Decimal } from "decimal.js";
|
import { Decimal } from "decimal.js";
|
||||||
@@ -12,7 +12,7 @@ export const paramsToFeatureOptions = ({
|
|||||||
price,
|
price,
|
||||||
feature,
|
feature,
|
||||||
}: {
|
}: {
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
price: Price;
|
price: Price;
|
||||||
feature: Feature;
|
feature: Feature;
|
||||||
}): FeatureOptions | undefined => {
|
}): FeatureOptions | undefined => {
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export const executeAutumnBillingPlan = async ({
|
|||||||
await CusProductService.update({
|
await CusProductService.update({
|
||||||
db,
|
db,
|
||||||
cusProductId: updateCustomerProduct.id,
|
cusProductId: updateCustomerProduct.id,
|
||||||
updates: { options: updateCustomerProduct.options },
|
updates: {
|
||||||
|
options: updateCustomerProduct.options,
|
||||||
|
status: updateCustomerProduct.status,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -16,24 +16,18 @@ export const executeBillingPlan = async ({
|
|||||||
}) => {
|
}) => {
|
||||||
logBillingPlan({ ctx, billingPlan });
|
logBillingPlan({ ctx, billingPlan });
|
||||||
|
|
||||||
const enableProductImmediately =
|
const result = await executeStripeBillingPlan({
|
||||||
billingPlan.stripe.invoiceAction?.invoiceMode?.enableProductImmediately !==
|
|
||||||
false;
|
|
||||||
|
|
||||||
await executeStripeBillingPlan({
|
|
||||||
ctx,
|
ctx,
|
||||||
stripeBillingPlan: billingPlan.stripe,
|
billingPlan,
|
||||||
autumnBillingPlan: billingPlan.autumn,
|
|
||||||
billingContext,
|
billingContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
// if not enabling product immediately, it will be handled in webhook
|
if (result.deferred) return result;
|
||||||
if (enableProductImmediately) {
|
|
||||||
await executeAutumnBillingPlan({
|
await executeAutumnBillingPlan({
|
||||||
ctx,
|
ctx,
|
||||||
autumnBillingPlan: billingPlan.autumn,
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return { billingPlan };
|
return { billingPlan };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|||||||
import { InvoiceService } from "@/internal/invoices/InvoiceService";
|
import { InvoiceService } from "@/internal/invoices/InvoiceService";
|
||||||
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
|
import { getInvoiceItems } from "@/internal/invoices/invoiceUtils";
|
||||||
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice";
|
import { createAndFinalizeInvoice } from "@/internal/invoices/invoiceUtils/createAndFinalizeInvoice";
|
||||||
import type { UpdateSubscriptionContext } from "../subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionBillingContext } from "../billingContext";
|
||||||
import type { SubscriptionUpdateInvoiceAction } from "../typesOld";
|
import type { SubscriptionUpdateInvoiceAction } from "../typesOld";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +22,7 @@ export const executeInvoiceAction = async ({
|
|||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
invoiceAction: SubscriptionUpdateInvoiceAction;
|
invoiceAction: SubscriptionUpdateInvoiceAction;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
}) => {
|
}) => {
|
||||||
const { stripeCustomer, stripeSubscription, customerProduct } =
|
const { stripeCustomer, stripeSubscription, customerProduct } =
|
||||||
updateSubscriptionContext;
|
updateSubscriptionContext;
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
import type { AutumnContext } from "../../../../honoUtils/HonoEnv";
|
|
||||||
import type { StripeSubscriptionAction } from "../types/billingPlan";
|
|
||||||
import { executeStripeSubscriptionUpdate } from "./executeStripeSubscriptionActions/executeStripeSubscriptionUpdate";
|
|
||||||
|
|
||||||
export const executeStripeSubAction = async ({
|
|
||||||
ctx,
|
|
||||||
stripeSubAction,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
stripeSubAction: StripeSubscriptionAction;
|
|
||||||
}) => {
|
|
||||||
const { logger } = ctx;
|
|
||||||
|
|
||||||
switch (stripeSubAction.type) {
|
|
||||||
case "update":
|
|
||||||
logger.info("Executing Stripe subscription update");
|
|
||||||
return await executeStripeSubscriptionUpdate({
|
|
||||||
ctx,
|
|
||||||
stripeSubscriptionAction: stripeSubAction,
|
|
||||||
});
|
|
||||||
|
|
||||||
case "create":
|
|
||||||
logger.info("Executing Stripe subscription create");
|
|
||||||
throw new Error("Stripe subscription create not yet implemented");
|
|
||||||
|
|
||||||
case "cancel_immediately":
|
|
||||||
logger.info("Executing Stripe subscription cancel immediately");
|
|
||||||
throw new Error(
|
|
||||||
"Stripe subscription cancel immediately not yet implemented",
|
|
||||||
);
|
|
||||||
|
|
||||||
case "cancel_at_period_end":
|
|
||||||
logger.info("Executing Stripe subscription cancel at period end");
|
|
||||||
throw new Error(
|
|
||||||
"Stripe subscription cancel at period end not yet implemented",
|
|
||||||
);
|
|
||||||
|
|
||||||
case "none":
|
|
||||||
logger.info("No Stripe subscription action required");
|
|
||||||
return;
|
|
||||||
|
|
||||||
default:
|
|
||||||
throw new Error(
|
|
||||||
`Unknown Stripe subscription action type: ${stripeSubAction.type}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
|
|
||||||
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
|
||||||
import { executeBillingPlan } from "../execute/executeBillingPlan";
|
|
||||||
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
|
|
||||||
import { evaluateSubscriptionUpdatePlan } from "../subscriptionUpdate/evaluate/evaluateSubscriptionUpdatePlan";
|
|
||||||
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
|
|
||||||
|
|
||||||
export const handleApiSubscriptionUpdate = createRoute({
|
|
||||||
body: SubscriptionUpdateV0ParamsSchema,
|
|
||||||
handler: async (c) => {
|
|
||||||
const ctx = c.get("ctx");
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({
|
|
||||||
ctx,
|
|
||||||
params: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const autumnBillingPlan = await computeSubscriptionUpdatePlan({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const stripeBillingPlan = evaluateSubscriptionUpdatePlan({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params: body,
|
|
||||||
autumnBillingPlan,
|
|
||||||
});
|
|
||||||
|
|
||||||
await executeBillingPlan({
|
|
||||||
ctx,
|
|
||||||
billingContext: updateSubscriptionContext,
|
|
||||||
billingPlan: {
|
|
||||||
autumn: autumnBillingPlan,
|
|
||||||
stripe: stripeBillingPlan,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json({ success: true }, 200);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
|
||||||
|
import { computeSubscriptionUpdatePlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
|
||||||
|
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
||||||
|
import { executeBillingPlan } from "../execute/executeBillingPlan";
|
||||||
|
import { evaluateStripeBillingPlan } from "../providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||||
|
import { fetchUpdateSubscriptionBillingContext } from "../subscriptionUpdate/fetch/fetchUpdateSubscriptionBillingContext";
|
||||||
|
|
||||||
|
export const handleUpdateSubscription = createRoute({
|
||||||
|
body: UpdateSubscriptionV0ParamsSchema,
|
||||||
|
handler: async (c) => {
|
||||||
|
const ctx = c.get("ctx");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const updateSubscriptionBillingContext =
|
||||||
|
await fetchUpdateSubscriptionBillingContext({
|
||||||
|
ctx,
|
||||||
|
params: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const autumnBillingPlan = await computeSubscriptionUpdatePlan({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionBillingContext,
|
||||||
|
params: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeBillingPlan = evaluateStripeBillingPlan({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionBillingContext,
|
||||||
|
autumnBillingPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
await executeBillingPlan({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionBillingContext,
|
||||||
|
billingPlan: {
|
||||||
|
autumn: autumnBillingPlan,
|
||||||
|
stripe: stripeBillingPlan,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json({ success: true }, 200);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,29 +1,34 @@
|
|||||||
import type { LineItem } from "@autumn/shared";
|
import type { LineItem } from "@autumn/shared";
|
||||||
import type {
|
import type { StripeInvoiceAction } from "../../../types/billingPlan";
|
||||||
InvoiceMode,
|
import { lineItemsToInvoiceAddLinesParams } from "../utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
|
||||||
StripeInvoiceAction,
|
|
||||||
} from "../../../types/billingPlan";
|
|
||||||
import { lineItemsToStripeLines } from "../utils/invoiceLines/lineItemsToStripeLines";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a Stripe invoice action from Autumn line items.
|
* Builds a StripeInvoiceAction for immediate charges.
|
||||||
* Returns undefined if no line items are provided.
|
* Filters for line items where chargeImmediately === true.
|
||||||
|
* Returns undefined if no immediate line items are provided.
|
||||||
*/
|
*/
|
||||||
export const buildStripeInvoiceAction = ({
|
export const buildStripeInvoiceAction = ({
|
||||||
autumnLineItems,
|
lineItems,
|
||||||
invoiceMode,
|
|
||||||
}: {
|
}: {
|
||||||
autumnLineItems: LineItem[];
|
lineItems: LineItem[];
|
||||||
invoiceMode?: InvoiceMode;
|
|
||||||
}): StripeInvoiceAction | undefined => {
|
}): StripeInvoiceAction | undefined => {
|
||||||
if (autumnLineItems.length === 0) {
|
const immediateLineItems = lineItems.filter(
|
||||||
|
(line) => line.chargeImmediately === true,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (immediateLineItems.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lines = lineItemsToStripeLines({ lineItems: autumnLineItems });
|
const lines = lineItemsToInvoiceAddLinesParams({
|
||||||
|
lineItems: immediateLineItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (lines.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
addLineParams: { lines },
|
addLineParams: { lines },
|
||||||
invoiceMode,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { LineItem } from "@autumn/shared";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import type { StripeInvoiceItemsAction } from "../../../types/billingPlan";
|
||||||
|
import { lineItemsToCreateInvoiceItemsParams } from "../utils/invoiceLines/lineItemsToCreateInvoiceItemsParams";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a StripeInvoiceItemsAction for deferred charges (added to next cycle).
|
||||||
|
* Filters for line items where chargeImmediately === false.
|
||||||
|
* Returns undefined if no deferred line items are provided.
|
||||||
|
*/
|
||||||
|
export const buildStripeInvoiceItemsAction = ({
|
||||||
|
lineItems,
|
||||||
|
billingContext,
|
||||||
|
}: {
|
||||||
|
lineItems: LineItem[];
|
||||||
|
billingContext: BillingContext;
|
||||||
|
}): StripeInvoiceItemsAction | undefined => {
|
||||||
|
const deferredLineItems = lineItems.filter(
|
||||||
|
(line) => line.chargeImmediately === false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (deferredLineItems.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripeCustomerId = billingContext.stripeCustomer?.id;
|
||||||
|
const stripeSubscriptionId = billingContext.stripeSubscription?.id;
|
||||||
|
|
||||||
|
const createInvoiceItems = lineItemsToCreateInvoiceItemsParams({
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
lineItems: deferredLineItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (createInvoiceItems.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
createInvoiceItems,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { FullCusProduct } from "@autumn/shared";
|
import type { FullCusProduct } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
|
||||||
import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
import { buildStripeSubscriptionItemsUpdate } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/buildStripeSubscriptionItemsUpdate";
|
||||||
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
|
import { buildStripeSubscriptionCreateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionCreateAction";
|
||||||
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
|
import { buildStripeSubscriptionUpdateAction } from "@server/internal/billing/v2/providers/stripe/utils/subscriptions/buildStripeSubscriptionUpdateAction";
|
||||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
|
||||||
import type {
|
import type {
|
||||||
FreeTrialPlan,
|
FreeTrialPlan,
|
||||||
StripeSubscriptionAction,
|
StripeSubscriptionAction,
|
||||||
@@ -13,13 +13,13 @@ import type {
|
|||||||
export const buildStripeSubscriptionAction = ({
|
export const buildStripeSubscriptionAction = ({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
updatedCustomerProducts,
|
finalCustomerProducts,
|
||||||
stripeSubscriptionScheduleAction,
|
stripeSubscriptionScheduleAction,
|
||||||
freeTrialPlan,
|
freeTrialPlan,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
billingContext: UpdateSubscriptionContext;
|
billingContext: BillingContext;
|
||||||
updatedCustomerProducts: FullCusProduct[];
|
finalCustomerProducts: FullCusProduct[];
|
||||||
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
|
stripeSubscriptionScheduleAction?: StripeSubscriptionScheduleAction;
|
||||||
freeTrialPlan?: FreeTrialPlan;
|
freeTrialPlan?: FreeTrialPlan;
|
||||||
}): StripeSubscriptionAction | undefined => {
|
}): StripeSubscriptionAction | undefined => {
|
||||||
@@ -28,11 +28,9 @@ export const buildStripeSubscriptionAction = ({
|
|||||||
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
|
const subItemsUpdate = buildStripeSubscriptionItemsUpdate({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
updatedCustomerProducts,
|
finalCustomerProducts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1. Compute the action type
|
|
||||||
|
|
||||||
// Case 1: No subscription and sub items update is empty -> no action
|
// Case 1: No subscription and sub items update is empty -> no action
|
||||||
if (!stripeSubscription && subItemsUpdate.length === 0) {
|
if (!stripeSubscription && subItemsUpdate.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import type { FullCusProduct } from "@autumn/shared";
|
import type { FullCusProduct } from "@autumn/shared";
|
||||||
import { msToSeconds } from "@autumn/shared";
|
import {
|
||||||
|
isCustomerProductOnStripeSubscription,
|
||||||
|
isCustomerProductOnStripeSubscriptionSchedule,
|
||||||
|
msToSeconds,
|
||||||
|
RELEVANT_STATUSES,
|
||||||
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||||
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
|
import type { BillingContext } from "@server/internal/billing/v2/billingContext";
|
||||||
import { getFinalCustomerProductsState } from "@server/internal/billing/v2/utils/getFinalCustomerProductsState";
|
import { buildSchedulePhases } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/buildSchedulePhases";
|
||||||
import { buildSchedulePhases } from "@server/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/buildSchedulePhases";
|
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan";
|
import type { StripeSubscriptionScheduleAction } from "@/internal/billing/v2/types/billingPlan";
|
||||||
|
|
||||||
@@ -46,31 +50,42 @@ const filterEmptyPhases = (
|
|||||||
export const buildStripeSubscriptionScheduleAction = ({
|
export const buildStripeSubscriptionScheduleAction = ({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
addCustomerProducts = [],
|
finalCustomerProducts,
|
||||||
removeCustomerProducts = [],
|
|
||||||
trialEndsAt,
|
trialEndsAt,
|
||||||
nowMs,
|
nowMs,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
billingContext: BillingContext;
|
billingContext: BillingContext;
|
||||||
addCustomerProducts?: FullCusProduct[];
|
finalCustomerProducts: FullCusProduct[];
|
||||||
removeCustomerProducts?: FullCusProduct[];
|
|
||||||
trialEndsAt?: number;
|
trialEndsAt?: number;
|
||||||
nowMs: number;
|
nowMs: number;
|
||||||
}): StripeSubscriptionScheduleAction | undefined => {
|
}): StripeSubscriptionScheduleAction | undefined => {
|
||||||
const { stripeSubscriptionSchedule } = billingContext;
|
const { stripeSubscriptionSchedule, stripeSubscription } = billingContext;
|
||||||
|
|
||||||
// 1. Get final customer product state
|
// 1. Filter customer products by stripe subscription id or stripe subscription schedule ID?
|
||||||
const customerProducts = getFinalCustomerProductsState({
|
|
||||||
billingContext,
|
let customerProducts = stripeSubscription
|
||||||
addCustomerProducts,
|
? finalCustomerProducts.filter(
|
||||||
removeCustomerProducts,
|
(cp) =>
|
||||||
});
|
isCustomerProductOnStripeSubscription({
|
||||||
|
customerProduct: cp,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
}) ||
|
||||||
|
isCustomerProductOnStripeSubscriptionSchedule({
|
||||||
|
customerProduct: cp,
|
||||||
|
stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "",
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
customerProducts = customerProducts.filter((cp) =>
|
||||||
|
RELEVANT_STATUSES.includes(cp.status),
|
||||||
|
);
|
||||||
|
|
||||||
const phases = buildSchedulePhases({
|
const phases = buildSchedulePhases({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
customerProducts,
|
customerProducts: finalCustomerProducts,
|
||||||
trialEndsAt,
|
trialEndsAt,
|
||||||
nowMs,
|
nowMs,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import { buildStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/actionBuilders/buildStripeSubscriptionScheduleAction";
|
||||||
|
import { autumnBillingPlanToFinalFullCustomer } from "@/internal/billing/v2/utils/autumnBillingPlanToFinalFullCustomer";
|
||||||
|
import type { BillingContext } from "../../../billingContext";
|
||||||
|
import { buildStripeInvoiceAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceAction";
|
||||||
|
import { buildStripeInvoiceItemsAction } from "../../../providers/stripe/actionBuilders/buildStripeInvoiceItemsAction";
|
||||||
|
import { buildStripeSubscriptionAction } from "../../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
|
||||||
|
import type {
|
||||||
|
AutumnBillingPlan,
|
||||||
|
StripeBillingPlan,
|
||||||
|
} from "../../../types/billingPlan";
|
||||||
|
|
||||||
|
export const evaluateStripeBillingPlan = ({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
autumnBillingPlan,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
autumnBillingPlan: AutumnBillingPlan;
|
||||||
|
}): StripeBillingPlan => {
|
||||||
|
const finalFullCustomer = autumnBillingPlanToFinalFullCustomer({
|
||||||
|
billingContext,
|
||||||
|
autumnBillingPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeSubscriptionAction = buildStripeSubscriptionAction({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
finalCustomerProducts: finalFullCustomer.customer_products,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { autumnLineItems } = autumnBillingPlan;
|
||||||
|
|
||||||
|
const stripeInvoiceAction = buildStripeInvoiceAction({
|
||||||
|
lineItems: autumnLineItems,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeInvoiceItemsAction = buildStripeInvoiceItemsAction({
|
||||||
|
lineItems: autumnLineItems,
|
||||||
|
billingContext,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build stripe subscription schedule action
|
||||||
|
const stripeSubscriptionScheduleAction =
|
||||||
|
buildStripeSubscriptionScheduleAction({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
finalCustomerProducts: finalFullCustomer.customer_products,
|
||||||
|
trialEndsAt: autumnBillingPlan.freeTrialPlan?.trialEndsAt,
|
||||||
|
nowMs: billingContext.currentEpochMs,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
subscriptionAction: stripeSubscriptionAction,
|
||||||
|
invoiceAction: stripeInvoiceAction,
|
||||||
|
invoiceItemsAction: stripeInvoiceItemsAction,
|
||||||
|
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,124 +1,66 @@
|
|||||||
import { MetadataType } from "@autumn/shared";
|
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
|
||||||
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
|
import { addStripeSubscriptionScheduleIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionScheduleIdToBillingPlan";
|
||||||
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
|
import { executeStripeInvoiceAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeInvoiceAction";
|
||||||
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
|
import { executeStripeSubscriptionAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionAction";
|
||||||
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
|
import { executeStripeSubscriptionScheduleAction } from "@/internal/billing/v2/providers/stripe/execute/executeStripeSubscriptionScheduleAction";
|
||||||
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
|
import { createStripeInvoiceItems } from "@/internal/billing/v2/providers/stripe/utils/invoices/stripeInvoiceOps";
|
||||||
import type {
|
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||||
AutumnBillingPlan,
|
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
|
||||||
StripeBillingPlan,
|
|
||||||
StripeInvoiceMetadata,
|
|
||||||
} from "@/internal/billing/v2/types/billingPlan";
|
|
||||||
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
|
||||||
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
|
|
||||||
import { addSubIdToCache } from "@/internal/customers/cusCache/subCacheUtils";
|
|
||||||
import { MetadataService } from "@/internal/metadata/MetadataService";
|
|
||||||
import { generateId } from "@/utils/genUtils";
|
|
||||||
|
|
||||||
export const executeStripeBillingPlan = async ({
|
export const executeStripeBillingPlan = async ({
|
||||||
ctx,
|
ctx,
|
||||||
stripeBillingPlan,
|
billingPlan,
|
||||||
autumnBillingPlan,
|
|
||||||
billingContext,
|
billingContext,
|
||||||
|
resumeFromDeferred = false,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
stripeBillingPlan: StripeBillingPlan;
|
billingPlan: BillingPlan;
|
||||||
autumnBillingPlan: AutumnBillingPlan;
|
|
||||||
billingContext: BillingContext;
|
billingContext: BillingContext;
|
||||||
}) => {
|
resumeFromDeferred?: boolean;
|
||||||
|
}): Promise<StripeBillingPlanResult> => {
|
||||||
|
const { logger } = ctx;
|
||||||
const {
|
const {
|
||||||
subscriptionAction: stripeSubscriptionAction,
|
subscriptionAction: stripeSubscriptionAction,
|
||||||
invoiceAction: stripeInvoiceAction,
|
invoiceAction: stripeInvoiceAction,
|
||||||
|
invoiceItemsAction: stripeInvoiceItemsAction,
|
||||||
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
|
subscriptionScheduleAction: stripeSubscriptionScheduleAction,
|
||||||
} = stripeBillingPlan;
|
} = billingPlan.stripe;
|
||||||
|
|
||||||
const enableProductImmediately =
|
if (stripeInvoiceAction && !resumeFromDeferred) {
|
||||||
stripeInvoiceAction?.invoiceMode?.enableProductImmediately !== false;
|
const result = await executeStripeInvoiceAction({
|
||||||
|
|
||||||
if (stripeInvoiceAction) {
|
|
||||||
let invoiceMetadata: StripeInvoiceMetadata | undefined;
|
|
||||||
|
|
||||||
if (!enableProductImmediately) {
|
|
||||||
const metadataId = generateId("meta");
|
|
||||||
await MetadataService.insert({
|
|
||||||
db: ctx.db,
|
|
||||||
data: {
|
|
||||||
id: metadataId,
|
|
||||||
type: MetadataType.DeferredAutumnBillingPlan,
|
|
||||||
data: {
|
|
||||||
orgId: ctx.org.id,
|
|
||||||
env: ctx.env,
|
|
||||||
autumnBillingPlan,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
invoiceMetadata = { autumn_metadata_id: metadataId };
|
|
||||||
}
|
|
||||||
|
|
||||||
const { invoice } = await createInvoiceForBilling({
|
|
||||||
ctx,
|
ctx,
|
||||||
|
billingPlan,
|
||||||
billingContext,
|
billingContext,
|
||||||
stripeInvoiceAction,
|
|
||||||
invoiceMetadata,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (invoice) {
|
if (result.deferred) return result;
|
||||||
await upsertInvoiceFromBilling({
|
|
||||||
ctx,
|
|
||||||
stripeInvoice: invoice,
|
|
||||||
fullProducts: billingContext.fullProducts,
|
|
||||||
fullCustomer: billingContext.fullCustomer,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (stripeInvoiceItemsAction?.createInvoiceItems) {
|
||||||
|
logger.info(
|
||||||
|
"[executeStripeBillingPlan] Creating invoice items for next cycle",
|
||||||
|
);
|
||||||
|
await createStripeInvoiceItems({
|
||||||
|
ctx,
|
||||||
|
invoiceItems: stripeInvoiceItemsAction.createInvoiceItems,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let stripeSubscription: Stripe.Subscription | undefined =
|
let stripeSubscription: Stripe.Subscription | undefined =
|
||||||
billingContext.stripeSubscription;
|
billingContext.stripeSubscription;
|
||||||
|
|
||||||
if (stripeSubscriptionAction) {
|
if (stripeSubscriptionAction) {
|
||||||
// 1. Insert stripe subscription ID into cache
|
const result = await executeStripeSubscriptionAction({
|
||||||
if (stripeSubscription?.id) {
|
|
||||||
await addSubIdToCache({
|
|
||||||
subId: stripeSubscription.id,
|
|
||||||
scenario: "billing",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stripeSubscription = await executeStripeSubscriptionAction({
|
|
||||||
ctx,
|
ctx,
|
||||||
subscriptionAction: stripeSubscriptionAction,
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (stripeSubscription) {
|
if (result?.deferred) return result;
|
||||||
addStripeSubscriptionIdToBillingPlan({
|
|
||||||
autumnBillingPlan,
|
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add subscription to DB
|
stripeSubscription = result.stripeSubscription;
|
||||||
await upsertSubscriptionFromBilling({
|
|
||||||
ctx,
|
|
||||||
stripeSubscription,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// If the stripe subscription is canceled, remove the subscription from the billing plan
|
|
||||||
if (
|
|
||||||
stripeSubscription &&
|
|
||||||
isStripeSubscriptionCanceled(stripeSubscription)
|
|
||||||
) {
|
|
||||||
removeStripeSubscriptionIdFromBillingPlan({
|
|
||||||
autumnBillingPlan,
|
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
|
||||||
});
|
|
||||||
|
|
||||||
stripeSubscription = undefined;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (stripeSubscriptionScheduleAction) {
|
if (stripeSubscriptionScheduleAction) {
|
||||||
@@ -132,9 +74,11 @@ export const executeStripeBillingPlan = async ({
|
|||||||
|
|
||||||
if (stripeSubscriptionSchedule) {
|
if (stripeSubscriptionSchedule) {
|
||||||
addStripeSubscriptionScheduleIdToBillingPlan({
|
addStripeSubscriptionScheduleIdToBillingPlan({
|
||||||
autumnBillingPlan,
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id,
|
stripeSubscriptionScheduleId: stripeSubscriptionSchedule.id,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { stripeInvoice: undefined };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import { createInvoiceForBilling } from "@/internal/billing/v2/providers/stripe/utils/invoices/createInvoiceForBilling";
|
||||||
|
import type {
|
||||||
|
BillingPlan,
|
||||||
|
StripeInvoiceMetadata,
|
||||||
|
} from "@/internal/billing/v2/types/billingPlan";
|
||||||
|
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
|
||||||
|
import { upsertInvoiceFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertInvoiceFromBilling";
|
||||||
|
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
|
||||||
|
|
||||||
|
export const executeStripeInvoiceAction = async ({
|
||||||
|
ctx,
|
||||||
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingPlan: BillingPlan;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
}): Promise<StripeBillingPlanResult> => {
|
||||||
|
const { logger } = ctx;
|
||||||
|
|
||||||
|
let invoiceMetadata: StripeInvoiceMetadata | undefined;
|
||||||
|
|
||||||
|
const { invoiceAction: stripeInvoiceAction } = billingPlan.stripe;
|
||||||
|
|
||||||
|
if (!stripeInvoiceAction) {
|
||||||
|
return { stripeInvoice: undefined };
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info("[executeStripeInvoiceAction] Creating invoice for billing");
|
||||||
|
|
||||||
|
const { invoice } = await createInvoiceForBilling({
|
||||||
|
ctx,
|
||||||
|
billingContext,
|
||||||
|
stripeInvoiceAction,
|
||||||
|
invoiceMetadata,
|
||||||
|
});
|
||||||
|
|
||||||
|
const enableProductAfterInvoice =
|
||||||
|
billingContext.invoiceMode?.enableProductImmediately === false;
|
||||||
|
const invoiceActionRequired = invoice.status === "open";
|
||||||
|
|
||||||
|
// Insert metadata into DB
|
||||||
|
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
|
||||||
|
if (deferBillingPlan) {
|
||||||
|
logger.info(
|
||||||
|
`Deferring billing plan, enableProductAfterInvoice: ${enableProductAfterInvoice}, invoiceActionRequired: ${invoiceActionRequired}`,
|
||||||
|
);
|
||||||
|
await insertMetadataFromBillingPlan({
|
||||||
|
ctx,
|
||||||
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
|
enableProductAfterInvoice,
|
||||||
|
invoiceActionRequired,
|
||||||
|
stripeInvoice: invoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripeInvoice: invoice,
|
||||||
|
deferred: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (invoice) {
|
||||||
|
logger.info("[executeStripeInvoiceAction] Upserting invoice from billing");
|
||||||
|
await upsertInvoiceFromBilling({
|
||||||
|
ctx,
|
||||||
|
stripeInvoice: invoice,
|
||||||
|
fullProducts: billingContext.fullProducts,
|
||||||
|
fullCustomer: billingContext.fullCustomer,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
`[executeStripeInvoiceAction] Completed, invoice: ${invoice?.id}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { stripeInvoice: invoice };
|
||||||
|
};
|
||||||
@@ -1,13 +1,32 @@
|
|||||||
|
import { InternalError } from "@autumn/shared";
|
||||||
|
import type Stripe from "stripe";
|
||||||
import { createStripeCli } from "@/external/connect/createStripeCli";
|
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||||
|
import { isStripeSubscriptionCanceled } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { StripeSubscriptionAction } from "@/internal/billing/v2/types/billingPlan";
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import { addStripeSubscriptionIdToBillingPlan } from "@/internal/billing/v2/execute/addStripeSubscriptionIdToBillingPlan";
|
||||||
|
import { removeStripeSubscriptionIdFromBillingPlan } from "@/internal/billing/v2/execute/removeStripeSubscriptionIdFromBillingPlan";
|
||||||
|
import type {
|
||||||
|
BillingPlan,
|
||||||
|
StripeSubscriptionAction,
|
||||||
|
} from "@/internal/billing/v2/types/billingPlan";
|
||||||
|
import type { StripeBillingPlanResult } from "@/internal/billing/v2/types/stripeBillingPlanResult";
|
||||||
|
import { upsertSubscriptionFromBilling } from "@/internal/billing/v2/utils/upsertFromStripe/upsertSubscriptionFromBilling";
|
||||||
|
import { insertMetadataFromBillingPlan } from "@/internal/metadata/utils/insertMetadataFromBillingPlan";
|
||||||
|
|
||||||
export const executeStripeSubscriptionAction = async ({
|
type InvoiceModeParams = {
|
||||||
|
collection_method?: "send_invoice";
|
||||||
|
days_until_due?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const executeSubscriptionOperation = async ({
|
||||||
ctx,
|
ctx,
|
||||||
subscriptionAction,
|
subscriptionAction,
|
||||||
|
invoiceModeParams,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
subscriptionAction: StripeSubscriptionAction;
|
subscriptionAction: StripeSubscriptionAction;
|
||||||
|
invoiceModeParams: InvoiceModeParams;
|
||||||
}) => {
|
}) => {
|
||||||
const { org, env } = ctx;
|
const { org, env } = ctx;
|
||||||
const stripeClient = createStripeCli({ org, env });
|
const stripeClient = createStripeCli({ org, env });
|
||||||
@@ -16,13 +35,118 @@ export const executeStripeSubscriptionAction = async ({
|
|||||||
case "update":
|
case "update":
|
||||||
return await stripeClient.subscriptions.update(
|
return await stripeClient.subscriptions.update(
|
||||||
subscriptionAction.stripeSubscriptionId,
|
subscriptionAction.stripeSubscriptionId,
|
||||||
subscriptionAction.params,
|
{
|
||||||
|
...subscriptionAction.params,
|
||||||
|
...invoiceModeParams,
|
||||||
|
expand: ["latest_invoice"],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
case "create":
|
case "create":
|
||||||
return await stripeClient.subscriptions.create(subscriptionAction.params);
|
return await stripeClient.subscriptions.create({
|
||||||
|
...subscriptionAction.params,
|
||||||
|
...invoiceModeParams,
|
||||||
|
expand: ["latest_invoice"],
|
||||||
|
});
|
||||||
case "cancel":
|
case "cancel":
|
||||||
return await stripeClient.subscriptions.cancel(
|
return await stripeClient.subscriptions.cancel(
|
||||||
subscriptionAction.stripeSubscriptionId,
|
subscriptionAction.stripeSubscriptionId,
|
||||||
|
{
|
||||||
|
expand: ["latest_invoice"],
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new InternalError({
|
||||||
|
message: "Invalid subscription action type",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const executeStripeSubscriptionAction = async ({
|
||||||
|
ctx,
|
||||||
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingPlan: BillingPlan;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
}): Promise<StripeBillingPlanResult> => {
|
||||||
|
// 1. Perform stripe subscription operation
|
||||||
|
const { subscriptionAction } = billingPlan.stripe;
|
||||||
|
|
||||||
|
if (!subscriptionAction) return {};
|
||||||
|
|
||||||
|
// Invoice mode:
|
||||||
|
const invoiceMode = billingContext.invoiceMode;
|
||||||
|
const invoiceModeParams = invoiceMode
|
||||||
|
? {
|
||||||
|
collection_method: "send_invoice" as const,
|
||||||
|
days_until_due: 30,
|
||||||
|
}
|
||||||
|
: {};
|
||||||
|
|
||||||
|
let stripeSubscription: Stripe.Subscription | undefined =
|
||||||
|
await executeSubscriptionOperation({
|
||||||
|
ctx,
|
||||||
|
subscriptionAction,
|
||||||
|
invoiceModeParams,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestStripeInvoice =
|
||||||
|
subscriptionAction.type === "create"
|
||||||
|
? (stripeSubscription.latest_invoice as Stripe.Invoice)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
// Defer billing plan
|
||||||
|
const enableProductAfterInvoice =
|
||||||
|
invoiceMode?.enableProductImmediately === false;
|
||||||
|
|
||||||
|
const invoiceActionRequired =
|
||||||
|
subscriptionAction.type === "create" &&
|
||||||
|
latestStripeInvoice?.status === "open";
|
||||||
|
|
||||||
|
const deferBillingPlan = enableProductAfterInvoice || invoiceActionRequired;
|
||||||
|
|
||||||
|
if (deferBillingPlan) {
|
||||||
|
await insertMetadataFromBillingPlan({
|
||||||
|
ctx,
|
||||||
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
|
enableProductAfterInvoice,
|
||||||
|
invoiceActionRequired,
|
||||||
|
stripeInvoice: latestStripeInvoice,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripeInvoice: latestStripeInvoice,
|
||||||
|
stripeSubscription,
|
||||||
|
deferred: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
addStripeSubscriptionIdToBillingPlan({
|
||||||
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add subscription to DB
|
||||||
|
await upsertSubscriptionFromBilling({
|
||||||
|
ctx,
|
||||||
|
stripeSubscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
// If the stripe subscription is canceled, remove the subscription from the billing plan
|
||||||
|
if (isStripeSubscriptionCanceled(stripeSubscription)) {
|
||||||
|
removeStripeSubscriptionIdFromBillingPlan({
|
||||||
|
autumnBillingPlan: billingPlan.autumn,
|
||||||
|
stripeSubscriptionId: stripeSubscription.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
stripeSubscription = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
stripeSubscription,
|
||||||
|
stripeInvoice: latestStripeInvoice,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { atmnToStripeAmount, type LineItem, msToSeconds } from "@autumn/shared";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a single LineItem to Stripe.InvoiceItemCreateParams
|
||||||
|
*/
|
||||||
|
const toStripeCreateInvoiceItemParams = ({
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
lineItem,
|
||||||
|
}: {
|
||||||
|
stripeCustomerId: string;
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
lineItem: LineItem;
|
||||||
|
}): Stripe.InvoiceItemCreateParams => {
|
||||||
|
const { finalAmount, description, context } = lineItem;
|
||||||
|
const { billingPeriod, currency } = context;
|
||||||
|
|
||||||
|
return {
|
||||||
|
customer: stripeCustomerId,
|
||||||
|
subscription: stripeSubscriptionId,
|
||||||
|
amount: atmnToStripeAmount({ amount: finalAmount }),
|
||||||
|
currency,
|
||||||
|
description,
|
||||||
|
period: billingPeriod
|
||||||
|
? {
|
||||||
|
start: msToSeconds(billingPeriod.start),
|
||||||
|
end: msToSeconds(billingPeriod.end),
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an array of LineItems to Stripe.InvoiceItemCreateParams[]
|
||||||
|
*/
|
||||||
|
export const lineItemsToCreateInvoiceItemsParams = ({
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
lineItems,
|
||||||
|
}: {
|
||||||
|
stripeCustomerId: string;
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
lineItems: LineItem[];
|
||||||
|
}): Stripe.InvoiceItemCreateParams[] => {
|
||||||
|
return lineItems.map((lineItem) =>
|
||||||
|
toStripeCreateInvoiceItemParams({
|
||||||
|
stripeCustomerId,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
lineItem,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import type Stripe from "stripe";
|
|||||||
/**
|
/**
|
||||||
* Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
|
* Converts a single LineItem to Stripe.InvoiceAddLinesParams.Line
|
||||||
*/
|
*/
|
||||||
export const lineItemToStripeLine = ({
|
const toStripeAddLineParams = ({
|
||||||
lineItem,
|
lineItem,
|
||||||
}: {
|
}: {
|
||||||
lineItem: LineItem;
|
lineItem: LineItem;
|
||||||
@@ -27,10 +27,10 @@ export const lineItemToStripeLine = ({
|
|||||||
/**
|
/**
|
||||||
* Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[]
|
* Converts an array of LineItems to Stripe.InvoiceAddLinesParams.Line[]
|
||||||
*/
|
*/
|
||||||
export const lineItemsToStripeLines = ({
|
export const lineItemsToInvoiceAddLinesParams = ({
|
||||||
lineItems,
|
lineItems,
|
||||||
}: {
|
}: {
|
||||||
lineItems: LineItem[];
|
lineItems: LineItem[];
|
||||||
}): Stripe.InvoiceAddLinesParams.Line[] => {
|
}): Stripe.InvoiceAddLinesParams.Line[] => {
|
||||||
return lineItems.map((lineItem) => lineItemToStripeLine({ lineItem }));
|
return lineItems.map((lineItem) => toStripeAddLineParams({ lineItem }));
|
||||||
};
|
};
|
||||||
@@ -27,7 +27,9 @@ export const createInvoiceForBilling = async ({
|
|||||||
invoiceMetadata?: StripeInvoiceMetadata;
|
invoiceMetadata?: StripeInvoiceMetadata;
|
||||||
}): Promise<PayInvoiceResult> => {
|
}): Promise<PayInvoiceResult> => {
|
||||||
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||||
const { addLineParams, invoiceMode } = stripeInvoiceAction;
|
const { addLineParams } = stripeInvoiceAction;
|
||||||
|
const invoiceMode = billingContext.invoiceMode;
|
||||||
|
|
||||||
const shouldFinalizeInvoice = invoiceMode
|
const shouldFinalizeInvoice = invoiceMode
|
||||||
? invoiceMode.finalizeInvoice
|
? invoiceMode.finalizeInvoice
|
||||||
: true;
|
: true;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
|
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
// Create Invoice
|
// Create Invoice
|
||||||
@@ -84,3 +86,22 @@ export const finalizeStripeInvoice = async ({
|
|||||||
|
|
||||||
return invoice;
|
return invoice;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// Create Invoice Items
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
export type CreateStripeInvoiceItemsParams = {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
invoiceItems: Stripe.InvoiceItemCreateParams[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const createStripeInvoiceItems = async ({
|
||||||
|
ctx,
|
||||||
|
invoiceItems,
|
||||||
|
}: CreateStripeInvoiceItemsParams): Promise<void> => {
|
||||||
|
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||||
|
for (const item of invoiceItems) {
|
||||||
|
await stripeCli.invoiceItems.create(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
|
import {
|
||||||
|
filterCustomerProductsByActiveStatuses,
|
||||||
|
filterCustomerProductsByStripeSubscriptionId,
|
||||||
|
} from "@autumn/shared";
|
||||||
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
import { customerProductToStripeItemSpecs } from "@server/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||||
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
|
import type { StripeItemSpec } from "@shared/models/billingModels/stripeAdapterModels/stripeItemSpec";
|
||||||
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
||||||
import {
|
|
||||||
ACTIVE_STATUSES,
|
|
||||||
isCustomerProductOnStripeSubscription,
|
|
||||||
} from "@shared/utils";
|
|
||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
|
import { stripeSubscriptionItemToStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/convertStripeSubscriptionItemUtils";
|
||||||
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
|
import { findStripeSubscriptionItemByStripePriceId } from "@/external/stripe/subscriptions/subscriptionItems/utils/findStripeSubscriptionItemUtils";
|
||||||
@@ -12,33 +12,6 @@ import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|||||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec";
|
import { findStripeItemSpecByStripePriceId } from "./findStripeItemSpec";
|
||||||
|
|
||||||
const getFinalCustomerProductsState = ({
|
|
||||||
billingContext,
|
|
||||||
updatedCustomerProducts = [],
|
|
||||||
}: {
|
|
||||||
billingContext: BillingContext;
|
|
||||||
updatedCustomerProducts?: FullCusProduct[];
|
|
||||||
}) => {
|
|
||||||
const { fullCustomer, stripeSubscription } = billingContext;
|
|
||||||
|
|
||||||
const customerProducts = stripeSubscription
|
|
||||||
? fullCustomer.customer_products.filter((customerProduct) =>
|
|
||||||
isCustomerProductOnStripeSubscription({
|
|
||||||
customerProduct,
|
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
return customerProducts.map((customerProduct) => {
|
|
||||||
const updated = updatedCustomerProducts.find(
|
|
||||||
(updatedCustomerProduct) =>
|
|
||||||
updatedCustomerProduct.id === customerProduct.id,
|
|
||||||
);
|
|
||||||
return updated ?? customerProduct;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const customerProductsToRecurringStripeItemSpecs = ({
|
const customerProductsToRecurringStripeItemSpecs = ({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
@@ -121,30 +94,31 @@ const stripeItemSpecsToSubItemsUpdate = ({
|
|||||||
export const buildStripeSubscriptionItemsUpdate = ({
|
export const buildStripeSubscriptionItemsUpdate = ({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
updatedCustomerProducts = [],
|
finalCustomerProducts,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
billingContext: BillingContext;
|
billingContext: BillingContext;
|
||||||
updatedCustomerProducts?: FullCusProduct[];
|
finalCustomerProducts: FullCusProduct[];
|
||||||
}) => {
|
}) => {
|
||||||
// 1. Get final customer product state (with updates applied)
|
// 1. Filter customer products by stripe subscription id
|
||||||
let customerProducts = getFinalCustomerProductsState({
|
let customerProducts = filterCustomerProductsByStripeSubscriptionId({
|
||||||
billingContext,
|
customerProducts: finalCustomerProducts,
|
||||||
updatedCustomerProducts,
|
stripeSubscriptionId: billingContext.stripeSubscription?.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
customerProducts = customerProducts.filter((customerProduct) =>
|
// 2. Filter customer products by active statuses
|
||||||
ACTIVE_STATUSES.includes(customerProduct.status),
|
customerProducts = filterCustomerProductsByActiveStatuses({
|
||||||
);
|
customerProducts,
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Get recurring subscription item array (doesn't include one off items)
|
// 3. Get recurring subscription item array (doesn't include one off items)
|
||||||
const recurringItems = customerProductsToRecurringStripeItemSpecs({
|
const recurringItems = customerProductsToRecurringStripeItemSpecs({
|
||||||
ctx,
|
ctx,
|
||||||
billingContext,
|
billingContext,
|
||||||
customerProducts,
|
customerProducts,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Diff it with the current subscription items
|
// 4. Diff it with the current subscription items
|
||||||
return stripeItemSpecsToSubItemsUpdate({
|
return stripeItemSpecsToSubItemsUpdate({
|
||||||
billingContext,
|
billingContext,
|
||||||
stripeItemSpecs: recurringItems,
|
stripeItemSpecs: recurringItems,
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import {
|
|||||||
import type Stripe from "stripe";
|
import type Stripe from "stripe";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionItems/customerProductToStripeItemSpecs";
|
import { customerProductToStripeItemSpecs } from "@/internal/billing/v2/providers/stripe/utils/subscriptionItems/customerProductToStripeItemSpecs";
|
||||||
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/utils/stripeAdapter/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
|
import { isCustomerProductActiveDuringPeriod } from "@/internal/billing/v2/providers/stripe/utils/subscriptionSchedules/isCustomerProductActiveAtEpochMs";
|
||||||
import { buildTransitionPoints } from "./buildTransitionPoints";
|
import { buildTransitionPoints } from "./buildTransitionPoints";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -25,14 +25,6 @@ export const buildStripeSubscriptionCreateAction = ({
|
|||||||
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
|
const isFreeTrialWithCardRequired = Boolean(freeTrial?.card_required);
|
||||||
const isCustomPaymentMethod = paymentMethod?.type === "custom";
|
const isCustomPaymentMethod = paymentMethod?.type === "custom";
|
||||||
|
|
||||||
// add_invoice_items: invoiceItems,
|
|
||||||
// collection_method: invoiceOnly ? "send_invoice" : "charge_automatically",
|
|
||||||
// days_until_due: invoiceOnly ? 30 : undefined,
|
|
||||||
// billing cycle anchor?
|
|
||||||
// discounts?
|
|
||||||
// expand: ["latest_invoice"],
|
|
||||||
// metadata?
|
|
||||||
|
|
||||||
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
|
const stripeSubscriptionCreateParams: Stripe.SubscriptionCreateParams = {
|
||||||
customer: stripeCustomer.id,
|
customer: stripeCustomer.id,
|
||||||
items: subItemsUpdate.map((item) => ({
|
items: subItemsUpdate.map((item) => ({
|
||||||
@@ -42,6 +34,8 @@ export const buildStripeSubscriptionCreateAction = ({
|
|||||||
|
|
||||||
billing_mode: { type: "flexible" },
|
billing_mode: { type: "flexible" },
|
||||||
|
|
||||||
|
collection_method: "charge_automatically",
|
||||||
|
|
||||||
payment_behavior: isCustomPaymentMethod
|
payment_behavior: isCustomPaymentMethod
|
||||||
? "default_incomplete"
|
? "default_incomplete"
|
||||||
: "error_if_incomplete",
|
: "error_if_incomplete",
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
InternalError,
|
InternalError,
|
||||||
type LineItemContext,
|
type LineItemContext,
|
||||||
orgToCurrency,
|
orgToCurrency,
|
||||||
|
priceToProrationConfig,
|
||||||
|
sumValues,
|
||||||
usagePriceToLineItem,
|
usagePriceToLineItem,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
@@ -52,6 +54,18 @@ export const buildQuantityUpdateLineItems = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get proration config based on price and direction (upgrade/downgrade)
|
||||||
|
const isUpgrade = quantityDifferenceForEntitlements > 0;
|
||||||
|
const { shouldApplyProration, chargeImmediately, skipLineItems } =
|
||||||
|
priceToProrationConfig({
|
||||||
|
price: customerPrice.price,
|
||||||
|
isUpgrade,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (skipLineItems) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
// Clone entitlement with updated quantity for the charge line item
|
// Clone entitlement with updated quantity for the charge line item
|
||||||
const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({
|
const newCustomerEntitlement = cloneEntitlementWithUpdatedQuantity({
|
||||||
customerEntitlement: prepaidCustomerEntitlement,
|
customerEntitlement: prepaidCustomerEntitlement,
|
||||||
@@ -76,12 +90,23 @@ export const buildQuantityUpdateLineItems = ({
|
|||||||
...lineItemContext,
|
...lineItemContext,
|
||||||
direction: "refund",
|
direction: "refund",
|
||||||
},
|
},
|
||||||
|
shouldProrateOverride: shouldApplyProration,
|
||||||
|
chargeImmediatelyOverride: chargeImmediately,
|
||||||
});
|
});
|
||||||
|
|
||||||
const chargeLineItem = usagePriceToLineItem({
|
const chargeLineItem = usagePriceToLineItem({
|
||||||
cusEnt: newCustomerEntitlement,
|
cusEnt: newCustomerEntitlement,
|
||||||
context: lineItemContext,
|
context: lineItemContext,
|
||||||
|
shouldProrateOverride: shouldApplyProration,
|
||||||
|
chargeImmediatelyOverride: chargeImmediately,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Don't return line items if they sum to 0
|
||||||
|
if (
|
||||||
|
sumValues([refundLineItem.finalAmount, chargeLineItem.finalAmount]) === 0
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
return [refundLineItem, chargeLineItem];
|
return [refundLineItem, chargeLineItem];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod";
|
import { getLineItemBillingPeriod } from "@shared/utils/billingUtils/cycleUtils/getLineItemBillingPeriod";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld";
|
import type { QuantityUpdateDetails } from "@/internal/billing/v2/typesOld";
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
|
||||||
import { buildQuantityUpdateLineItems } from "./buildQuantityUpdateLineItems";
|
import { buildQuantityUpdateLineItems } from "./buildQuantityUpdateLineItems";
|
||||||
import { calculateCustomerEntitlementChange } from "./quantityUpdateUtils/calculateCustomerEntitlementChange";
|
import { calculateCustomerEntitlementChange } from "./quantityUpdateUtils/calculateCustomerEntitlementChange";
|
||||||
import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences";
|
import { calculateQuantityDifferences } from "./quantityUpdateUtils/calculateQuantityDifferences";
|
||||||
@@ -33,7 +33,7 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
updatedOptions: FeatureOptions;
|
updatedOptions: FeatureOptions;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
}): QuantityUpdateDetails => {
|
}): QuantityUpdateDetails => {
|
||||||
const { customerProduct, currentEpochMs, billingCycleAnchorMs } =
|
const { customerProduct, currentEpochMs, billingCycleAnchorMs } =
|
||||||
updateSubscriptionContext;
|
updateSubscriptionContext;
|
||||||
@@ -78,7 +78,15 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
const priceConfiguration = resolvePriceForQuantityUpdate({
|
const priceConfiguration = resolvePriceForQuantityUpdate({
|
||||||
customerProduct,
|
customerProduct,
|
||||||
updatedOptions,
|
updatedOptions,
|
||||||
isUpgrade: quantityDifferences.isUpgrade,
|
});
|
||||||
|
|
||||||
|
const { customerEntitlementId, customerEntitlementBalanceChange } =
|
||||||
|
calculateCustomerEntitlementChange({
|
||||||
|
quantityDifferenceForEntitlements:
|
||||||
|
quantityDifferences.quantityDifferenceForEntitlements,
|
||||||
|
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
||||||
|
customerPrice: priceConfiguration.customerPrice,
|
||||||
|
customerEntitlements: customerProduct.customer_entitlements,
|
||||||
});
|
});
|
||||||
|
|
||||||
const billingPeriod = getLineItemBillingPeriod({
|
const billingPeriod = getLineItemBillingPeriod({
|
||||||
@@ -103,15 +111,6 @@ export const computeQuantityUpdateDetails = ({
|
|||||||
currentEpochMs,
|
currentEpochMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { customerEntitlementId, customerEntitlementBalanceChange } =
|
|
||||||
calculateCustomerEntitlementChange({
|
|
||||||
quantityDifferenceForEntitlements:
|
|
||||||
quantityDifferences.quantityDifferenceForEntitlements,
|
|
||||||
billingUnitsPerQuantity: priceConfiguration.billingUnitsPerQuantity,
|
|
||||||
customerPrice: priceConfiguration.customerPrice,
|
|
||||||
customerEntitlements: customerProduct.customer_entitlements,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
featureId,
|
featureId,
|
||||||
customerEntitlementId,
|
customerEntitlementId,
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ import {
|
|||||||
isCustomerProductOneOff,
|
isCustomerProductOneOff,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
|
||||||
import type {
|
import type {
|
||||||
StripeInvoiceAction,
|
StripeInvoiceAction,
|
||||||
StripeSubscriptionAction,
|
StripeSubscriptionAction,
|
||||||
} from "@/internal/billing/v2/types/billingPlan";
|
} from "@/internal/billing/v2/types/billingPlan";
|
||||||
import { lineItemsToStripeLines } from "../../../providers/stripe/utils/invoiceLines/lineItemsToStripeLines";
|
import { lineItemsToInvoiceAddLinesParams } from "../../../providers/stripe/utils/invoiceLines/lineItemsToInvoiceAddLinesParams";
|
||||||
|
|
||||||
export const computeInvoiceAction = ({
|
export const computeInvoiceAction = ({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -21,7 +21,7 @@ export const computeInvoiceAction = ({
|
|||||||
billingCycleAnchor,
|
billingCycleAnchor,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
billingContext: UpdateSubscriptionContext;
|
billingContext: UpdateSubscriptionBillingContext;
|
||||||
newCustomerProduct: FullCusProduct;
|
newCustomerProduct: FullCusProduct;
|
||||||
stripeSubscriptionAction?: StripeSubscriptionAction;
|
stripeSubscriptionAction?: StripeSubscriptionAction;
|
||||||
billingCycleAnchor?: number;
|
billingCycleAnchor?: number;
|
||||||
@@ -61,7 +61,7 @@ export const computeInvoiceAction = ({
|
|||||||
testClockFrozenTime: billingContext.testClockFrozenTime,
|
testClockFrozenTime: billingContext.testClockFrozenTime,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addLineParams = lineItemsToStripeLines({
|
const addLineParams = lineItemsToInvoiceAddLinesParams({
|
||||||
lineItems: autumnLineItems,
|
lineItems: autumnLineItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
|
CusProductStatus,
|
||||||
cusProductToProduct,
|
cusProductToProduct,
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
secondsToMs,
|
secondsToMs,
|
||||||
|
type UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@server/honoUtils/HonoEnv";
|
||||||
import type { UpdateSubscriptionContext } from "@server/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionBillingContext } from "@server/internal/billing/v2/billingContext";
|
||||||
|
import { buildAutumnLineItems } from "@/internal/billing/v2/compute/computeAutumnUtils/buildAutumnLineItems";
|
||||||
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
|
import { computeSubscriptionUpdateFreeTrialPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFreeTrialPlan";
|
||||||
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
import { computeSubscriptionUpdateNewCustomerProduct } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateNewCustomerProduct";
|
||||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||||
@@ -16,8 +18,8 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
}) => {
|
}) => {
|
||||||
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
||||||
|
|
||||||
@@ -36,7 +38,7 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
customItems: params.items,
|
customItems: params.items,
|
||||||
});
|
});
|
||||||
|
|
||||||
updateSubscriptionContext.fullProducts.push(customFullProduct);
|
updateSubscriptionContext.fullProducts = [customFullProduct];
|
||||||
|
|
||||||
// 2. Compute the custom trial details
|
// 2. Compute the custom trial details
|
||||||
const { freeTrialPlan, customFreeTrial } =
|
const { freeTrialPlan, customFreeTrial } =
|
||||||
@@ -46,12 +48,10 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
fullProduct: customFullProduct,
|
fullProduct: customFullProduct,
|
||||||
});
|
});
|
||||||
|
|
||||||
const billingCycleAnchor =
|
updateSubscriptionContext.billingCycleAnchorMs =
|
||||||
freeTrialPlan.trialEndsAt ??
|
freeTrialPlan.trialEndsAt ??
|
||||||
secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
secondsToMs(stripeSubscription?.billing_cycle_anchor);
|
||||||
|
|
||||||
const nowMs = updateSubscriptionContext.testClockFrozenTime ?? Date.now();
|
|
||||||
|
|
||||||
// 3. Compute the new customer product
|
// 3. Compute the new customer product
|
||||||
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
const newFullCustomerProduct = computeSubscriptionUpdateNewCustomerProduct({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -59,85 +59,24 @@ export const computeSubscriptionUpdateCustomPlan = async ({
|
|||||||
params,
|
params,
|
||||||
fullProduct: customFullProduct,
|
fullProduct: customFullProduct,
|
||||||
freeTrialPlan,
|
freeTrialPlan,
|
||||||
billingCycleAnchor,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Line items?
|
const autumnLineItems = buildAutumnLineItems({
|
||||||
|
ctx,
|
||||||
|
newCustomerProducts: [newFullCustomerProduct],
|
||||||
|
deletedCustomerProduct: customerProduct,
|
||||||
|
billingContext: updateSubscriptionContext,
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
insertCustomerProducts: [newFullCustomerProduct],
|
insertCustomerProducts: [newFullCustomerProduct],
|
||||||
// updateCustomerProduct: {
|
updateCustomerProduct: {
|
||||||
// customerProduct: customerProduct,
|
...customerProduct,
|
||||||
// updates: {
|
status: CusProductStatus.Expired,
|
||||||
// status: CusProductStatus.Expired,
|
},
|
||||||
// },
|
|
||||||
// },
|
|
||||||
customPrices: customPrices,
|
customPrices: customPrices,
|
||||||
customEntitlements: customEnts,
|
customEntitlements: customEnts,
|
||||||
customFreeTrial: customFreeTrial,
|
customFreeTrial: customFreeTrial,
|
||||||
autumnLineItems: [],
|
autumnLineItems,
|
||||||
} satisfies AutumnBillingPlan;
|
} satisfies AutumnBillingPlan;
|
||||||
|
|
||||||
// 4. Create stripe prices
|
|
||||||
// const fullCustomer = updateSubscriptionContext.fullCustomer;
|
|
||||||
// await createStripeResourcesForProducts({
|
|
||||||
// ctx,
|
|
||||||
// fullCustomer,
|
|
||||||
// fullProducts: [customFullProduct],
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // 5. Build subscription schedule action
|
|
||||||
// const scheduleAction = buildStripeSubscriptionScheduleAction({
|
|
||||||
// ctx,
|
|
||||||
// billingContext: updateSubscriptionContext,
|
|
||||||
// addCustomerProducts: [newFullCustomerProduct],
|
|
||||||
// removeCustomerProducts: [customerProduct],
|
|
||||||
// trialEndsAt: freeTrialPlan.trialEndsAt,
|
|
||||||
// nowMs,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // 6. Compute Stripe subscription action
|
|
||||||
// const stripeSubscriptionAction = buildStripeSubscriptionAction({
|
|
||||||
// ctx,
|
|
||||||
// billingContext: updateSubscriptionContext,
|
|
||||||
// newCustomerProduct: newFullCustomerProduct,
|
|
||||||
// stripeSubscriptionScheduleAction: scheduleAction,
|
|
||||||
// freeTrialPlan,
|
|
||||||
// nowMs,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// // 6. Compute subscription schedule action
|
|
||||||
// const stripeInvoiceAction = computeInvoiceAction({
|
|
||||||
// ctx,
|
|
||||||
// billingContext: updateSubscriptionContext,
|
|
||||||
// newCustomerProduct: newFullCustomerProduct,
|
|
||||||
// stripeSubscriptionAction,
|
|
||||||
// billingCycleAnchor,
|
|
||||||
// });
|
|
||||||
|
|
||||||
// const billingPlan: BillingPlan = {
|
|
||||||
// stripe: {
|
|
||||||
// subscriptionAction: stripeSubscriptionAction,
|
|
||||||
// invoiceAction: stripeInvoiceAction,
|
|
||||||
// subscriptionScheduleAction: scheduleAction,
|
|
||||||
// },
|
|
||||||
|
|
||||||
// autumn: {
|
|
||||||
// freeTrialPlan,
|
|
||||||
// insertCustomerProducts: [newFullCustomerProduct],
|
|
||||||
|
|
||||||
// updateCustomerProduct: {
|
|
||||||
// customerProduct: customerProduct,
|
|
||||||
// updates: {
|
|
||||||
// status: CusProductStatus.Expired,
|
|
||||||
// },
|
|
||||||
// },
|
|
||||||
|
|
||||||
// customPrices: customPrices,
|
|
||||||
// customEntitlements: customEnts,
|
|
||||||
// customFreeTrial: customFreeTrial,
|
|
||||||
// },
|
|
||||||
// };
|
|
||||||
|
|
||||||
// return billingPlan;
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
InternalError,
|
InternalError,
|
||||||
isPrepaidPrice,
|
isPrepaidPrice,
|
||||||
priceToFeature,
|
priceToFeature,
|
||||||
type SubscriptionUpdateV0Params,
|
type UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
|
import { paramsToFeatureOptions } from "@/internal/billing/v2/compute/computeAutumnUtils/paramsToFeatureOptions";
|
||||||
@@ -23,7 +23,7 @@ export const computeSubscriptionUpdateFeatureQuantities = ({
|
|||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
fullProduct: FullProduct;
|
fullProduct: FullProduct;
|
||||||
currentCustomerProduct: FullCusProduct;
|
currentCustomerProduct: FullCusProduct;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
}) => {
|
}) => {
|
||||||
const newFeatureQuantities: FeatureOptions[] = [];
|
const newFeatureQuantities: FeatureOptions[] = [];
|
||||||
for (const price of fullProduct.prices) {
|
for (const price of fullProduct.prices) {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
FreeTrial,
|
FreeTrial,
|
||||||
FullProduct,
|
FullProduct,
|
||||||
SubscriptionUpdateV0Params,
|
UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import {
|
import {
|
||||||
addDuration,
|
addDuration,
|
||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
secondsToMs,
|
secondsToMs,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
import { isStripeSubscriptionTrialing } from "@/external/stripe/subscriptions/utils/classifyStripeSubscriptionUtils";
|
||||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
|
||||||
interface ComputeSubscriptionUpdateTrialDetailsResult {
|
interface ComputeSubscriptionUpdateTrialDetailsResult {
|
||||||
freeTrialPlan: {
|
freeTrialPlan: {
|
||||||
@@ -25,8 +25,8 @@ export const computeSubscriptionUpdateFreeTrialPlan = ({
|
|||||||
params,
|
params,
|
||||||
fullProduct,
|
fullProduct,
|
||||||
}: {
|
}: {
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
fullProduct: FullProduct;
|
fullProduct: FullProduct;
|
||||||
}): ComputeSubscriptionUpdateTrialDetailsResult => {
|
}): ComputeSubscriptionUpdateTrialDetailsResult => {
|
||||||
const { stripeSubscription, customerProduct, currentEpochMs } =
|
const { stripeSubscription, customerProduct, currentEpochMs } =
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
import {
|
import type { FullProduct, UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||||
type FullProduct,
|
|
||||||
InternalError,
|
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
} from "@autumn/shared";
|
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
import { computeSubscriptionUpdateFeatureQuantities } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateFeatureQuantities";
|
||||||
import type { UpdateSubscriptionContext } from "@/internal/billing/v2/subscriptionUpdate/fetch/updateSubscriptionContextSchema";
|
|
||||||
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
|
import type { FreeTrialPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||||
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
|
import { cusProductToExistingRollovers } from "@/internal/billing/v2/utils/handleExistingRollovers/cusProductToExistingRollovers";
|
||||||
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
|
import { cusProductToExistingUsages } from "@/internal/billing/v2/utils/handleExistingUsages/cusProductToExistingUsages";
|
||||||
@@ -17,29 +13,22 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
|||||||
updateSubscriptionContext,
|
updateSubscriptionContext,
|
||||||
fullProduct,
|
fullProduct,
|
||||||
freeTrialPlan,
|
freeTrialPlan,
|
||||||
billingCycleAnchor,
|
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
fullProduct: FullProduct;
|
fullProduct: FullProduct;
|
||||||
freeTrialPlan: FreeTrialPlan;
|
freeTrialPlan: FreeTrialPlan;
|
||||||
billingCycleAnchor?: number;
|
|
||||||
}) => {
|
}) => {
|
||||||
const {
|
const {
|
||||||
customerProduct,
|
customerProduct,
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
stripeSubscription,
|
stripeSubscription,
|
||||||
stripeSubscriptionSchedule,
|
stripeSubscriptionSchedule,
|
||||||
|
billingCycleAnchorMs,
|
||||||
currentEpochMs,
|
currentEpochMs,
|
||||||
} = updateSubscriptionContext;
|
} = updateSubscriptionContext;
|
||||||
|
|
||||||
if (!stripeSubscription) {
|
|
||||||
throw new InternalError({
|
|
||||||
message: `[Subscription Update] Stripe subscription not found`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 1. Get feature quantities
|
// 1. Get feature quantities
|
||||||
const existingUsages = cusProductToExistingUsages({
|
const existingUsages = cusProductToExistingUsages({
|
||||||
cusProduct: customerProduct,
|
cusProduct: customerProduct,
|
||||||
@@ -67,7 +56,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
|||||||
featureQuantities,
|
featureQuantities,
|
||||||
existingUsages,
|
existingUsages,
|
||||||
existingRollovers,
|
existingRollovers,
|
||||||
resetCycleAnchor: billingCycleAnchor ?? "now",
|
resetCycleAnchor: billingCycleAnchorMs ?? "now",
|
||||||
now: currentEpochMs,
|
now: currentEpochMs,
|
||||||
|
|
||||||
freeTrial: freeTrialPlan.freeTrial ?? null,
|
freeTrial: freeTrialPlan.freeTrial ?? null,
|
||||||
@@ -76,7 +65,7 @@ export const computeSubscriptionUpdateNewCustomerProduct = ({
|
|||||||
|
|
||||||
initOptions: {
|
initOptions: {
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
subscriptionId: stripeSubscription.id,
|
subscriptionId: stripeSubscription?.id,
|
||||||
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
subscriptionScheduleId: stripeSubscriptionSchedule?.id,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
import type { UpdateSubscriptionV0Params } from "@shared/index";
|
||||||
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
|
import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -7,7 +7,7 @@ import { SubscriptionUpdateIntentEnum } from "./computeSubscriptionUpdateSchema"
|
|||||||
* @returns The intent for the subscription update
|
* @returns The intent for the subscription update
|
||||||
*/
|
*/
|
||||||
export const computeSubscriptionUpdateIntent = (
|
export const computeSubscriptionUpdateIntent = (
|
||||||
params: SubscriptionUpdateV0Params,
|
params: UpdateSubscriptionV0Params,
|
||||||
): SubscriptionUpdateIntentEnum => {
|
): SubscriptionUpdateIntentEnum => {
|
||||||
if (params.options?.length && !params.items?.length)
|
if (params.options?.length && !params.items?.length)
|
||||||
return SubscriptionUpdateIntentEnum.UpdateQuantity;
|
return SubscriptionUpdateIntentEnum.UpdateQuantity;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { UpdateSubscriptionBillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan";
|
import { computeSubscriptionUpdateCustomPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateCustomPlan/computeSubscriptionUpdateCustomPlan";
|
||||||
import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan";
|
import { computeSubscriptionUpdateQuantityPlan } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateQuantityPlan";
|
||||||
import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema";
|
import { SubscriptionUpdateIntentEnum } from "@/internal/billing/v2/subscriptionUpdate/compute/computeSubscriptionUpdateSchema";
|
||||||
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
|
||||||
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
|
import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateIntent";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -15,12 +15,12 @@ import { computeSubscriptionUpdateIntent } from "./computeSubscriptionUpdateInte
|
|||||||
*/
|
*/
|
||||||
export const computeSubscriptionUpdatePlan = async ({
|
export const computeSubscriptionUpdatePlan = async ({
|
||||||
ctx,
|
ctx,
|
||||||
updateSubscriptionContext,
|
billingContext,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
billingContext: UpdateSubscriptionBillingContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
}): Promise<AutumnBillingPlan> => {
|
}): Promise<AutumnBillingPlan> => {
|
||||||
const intent = computeSubscriptionUpdateIntent(params);
|
const intent = computeSubscriptionUpdateIntent(params);
|
||||||
|
|
||||||
@@ -28,13 +28,13 @@ export const computeSubscriptionUpdatePlan = async ({
|
|||||||
case SubscriptionUpdateIntentEnum.UpdateQuantity:
|
case SubscriptionUpdateIntentEnum.UpdateQuantity:
|
||||||
return computeSubscriptionUpdateQuantityPlan({
|
return computeSubscriptionUpdateQuantityPlan({
|
||||||
ctx,
|
ctx,
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext: billingContext,
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
case SubscriptionUpdateIntentEnum.UpdatePlan:
|
case SubscriptionUpdateIntentEnum.UpdatePlan:
|
||||||
return await computeSubscriptionUpdateCustomPlan({
|
return await computeSubscriptionUpdateCustomPlan({
|
||||||
ctx,
|
ctx,
|
||||||
updateSubscriptionContext,
|
updateSubscriptionContext: billingContext,
|
||||||
params,
|
params,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { InternalError, type SubscriptionUpdateV0Params } from "@autumn/shared";
|
import { InternalError, type UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import type { AutumnBillingPlan } from "../../types/billingPlan";
|
import type { AutumnBillingPlan } from "../../types/billingPlan";
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
|
||||||
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
|
import { computeQuantityUpdateDetails } from "./computeQuantityUpdateDetails";
|
||||||
|
|
||||||
export const computeSubscriptionUpdateQuantityPlan = ({
|
export const computeSubscriptionUpdateQuantityPlan = ({
|
||||||
@@ -10,8 +10,8 @@ export const computeSubscriptionUpdateQuantityPlan = ({
|
|||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
}): AutumnBillingPlan => {
|
}): AutumnBillingPlan => {
|
||||||
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
priceToLineAmount,
|
priceToLineAmount,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { Decimal } from "decimal.js";
|
import { Decimal } from "decimal.js";
|
||||||
import type { UpdateSubscriptionContext } from "../../fetch/updateSubscriptionContextSchema";
|
import type { UpdateSubscriptionBillingContext } from "../../../billingContext";
|
||||||
import type { calculateQuantityDifferences } from "./calculateQuantityDifferences";
|
import type { calculateQuantityDifferences } from "./calculateQuantityDifferences";
|
||||||
import type { resolvePriceForQuantityUpdate } from "./resolvePriceForQuantityUpdate";
|
import type { resolvePriceForQuantityUpdate } from "./resolvePriceForQuantityUpdate";
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ export const calculateProrationAmount = ({
|
|||||||
quantityDifferences,
|
quantityDifferences,
|
||||||
billingPeriod,
|
billingPeriod,
|
||||||
}: {
|
}: {
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
updateSubscriptionContext: UpdateSubscriptionBillingContext;
|
||||||
previousOptions: FeatureOptions;
|
previousOptions: FeatureOptions;
|
||||||
updatedOptions: FeatureOptions;
|
updatedOptions: FeatureOptions;
|
||||||
priceConfiguration: ReturnType<typeof resolvePriceForQuantityUpdate>;
|
priceConfiguration: ReturnType<typeof resolvePriceForQuantityUpdate>;
|
||||||
|
|||||||
@@ -4,40 +4,31 @@ import {
|
|||||||
type FullCustomerPrice,
|
type FullCustomerPrice,
|
||||||
findCusPriceByFeature,
|
findCusPriceByFeature,
|
||||||
InternalError,
|
InternalError,
|
||||||
type OnDecrease,
|
|
||||||
type OnIncrease,
|
|
||||||
type Price,
|
type Price,
|
||||||
priceToProrationConfig,
|
|
||||||
type UsagePriceConfig,
|
type UsagePriceConfig,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Extracts and validates price configuration for a quantity update.
|
* Extracts and validates price configuration for a quantity update.
|
||||||
*
|
*
|
||||||
* Determines proration behavior (on_increase/on_decrease) based on quantity change direction.
|
* Finds the customer price and extracts billing units per quantity.
|
||||||
*
|
*
|
||||||
* @param updatedOptions - New feature options with target quantity
|
* @param updatedOptions - New feature options with target quantity
|
||||||
* @param customerProduct - Customer product with associated prices
|
* @param customerProduct - Customer product with associated prices
|
||||||
* @param previousOptions - Current feature options
|
* @returns Price config including billing units
|
||||||
* @returns Price config including proration rules and billing units
|
|
||||||
* @throws {InternalError} When internal_feature_id is missing or price not found
|
* @throws {InternalError} When internal_feature_id is missing or price not found
|
||||||
*/
|
*/
|
||||||
export const resolvePriceForQuantityUpdate = ({
|
export const resolvePriceForQuantityUpdate = ({
|
||||||
customerProduct,
|
customerProduct,
|
||||||
updatedOptions,
|
updatedOptions,
|
||||||
isUpgrade,
|
|
||||||
}: {
|
}: {
|
||||||
customerProduct: FullCusProduct;
|
customerProduct: FullCusProduct;
|
||||||
updatedOptions: FeatureOptions;
|
updatedOptions: FeatureOptions;
|
||||||
isUpgrade: boolean;
|
|
||||||
}): {
|
}): {
|
||||||
customerPrice: FullCustomerPrice;
|
customerPrice: FullCustomerPrice;
|
||||||
price: Price;
|
price: Price;
|
||||||
priceConfig: UsagePriceConfig;
|
priceConfig: UsagePriceConfig;
|
||||||
billingUnitsPerQuantity: number;
|
billingUnitsPerQuantity: number;
|
||||||
prorationBehaviorConfig: OnIncrease | OnDecrease;
|
|
||||||
shouldApplyProration: boolean;
|
|
||||||
shouldFinalizeInvoiceImmediately: boolean;
|
|
||||||
} => {
|
} => {
|
||||||
if (!updatedOptions.internal_feature_id) {
|
if (!updatedOptions.internal_feature_id) {
|
||||||
throw new InternalError({
|
throw new InternalError({
|
||||||
@@ -60,22 +51,10 @@ export const resolvePriceForQuantityUpdate = ({
|
|||||||
const priceConfig = price.config as UsagePriceConfig;
|
const priceConfig = price.config as UsagePriceConfig;
|
||||||
const billingUnitsPerQuantity = priceConfig.billing_units ?? 1;
|
const billingUnitsPerQuantity = priceConfig.billing_units ?? 1;
|
||||||
|
|
||||||
const {
|
|
||||||
prorationBehaviorConfig,
|
|
||||||
shouldApplyProration,
|
|
||||||
shouldFinalizeInvoiceImmediately,
|
|
||||||
} = priceToProrationConfig({
|
|
||||||
price,
|
|
||||||
isUpgrade,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
customerPrice,
|
customerPrice,
|
||||||
price,
|
price,
|
||||||
priceConfig,
|
priceConfig,
|
||||||
billingUnitsPerQuantity,
|
billingUnitsPerQuantity,
|
||||||
prorationBehaviorConfig,
|
|
||||||
shouldApplyProration,
|
|
||||||
shouldFinalizeInvoiceImmediately,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|
||||||
import { buildStripeInvoiceAction } from "../../providers/stripe/actionBuilders/buildStripeInvoiceAction";
|
|
||||||
import { buildStripeSubscriptionAction } from "../../providers/stripe/actionBuilders/buildStripeSubscriptionAction";
|
|
||||||
import type {
|
|
||||||
AutumnBillingPlan,
|
|
||||||
StripeBillingPlan,
|
|
||||||
} from "../../types/billingPlan";
|
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
|
||||||
|
|
||||||
export const evaluateSubscriptionUpdatePlan = ({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params,
|
|
||||||
autumnBillingPlan,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
|
||||||
params: SubscriptionUpdateV0Params;
|
|
||||||
autumnBillingPlan: AutumnBillingPlan;
|
|
||||||
}): StripeBillingPlan => {
|
|
||||||
const updatedCustomerProducts = autumnBillingPlan.updateCustomerProduct
|
|
||||||
? [autumnBillingPlan.updateCustomerProduct]
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const stripeSubscriptionAction = buildStripeSubscriptionAction({
|
|
||||||
ctx,
|
|
||||||
billingContext: updateSubscriptionContext,
|
|
||||||
updatedCustomerProducts,
|
|
||||||
});
|
|
||||||
|
|
||||||
const invoiceModeEnabled = params.invoice === true;
|
|
||||||
|
|
||||||
const stripeInvoiceAction = buildStripeInvoiceAction({
|
|
||||||
autumnLineItems: autumnBillingPlan.autumnLineItems,
|
|
||||||
invoiceMode: invoiceModeEnabled
|
|
||||||
? {
|
|
||||||
finalizeInvoice: params.finalize_invoice === true,
|
|
||||||
enableProductImmediately: params.enable_product_immediately !== false,
|
|
||||||
}
|
|
||||||
: undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
subscriptionAction: stripeSubscriptionAction,
|
|
||||||
invoiceAction: stripeInvoiceAction,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
import type { SubscriptionUpdateV0Params } from "@shared/index";
|
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
|
||||||
import type { SubscriptionUpdatePlan } from "@/internal/billing/v2/typesOld";
|
|
||||||
import { executeCusProductActions } from "../../execute/executeAutumnActions/executeCusProductActions";
|
|
||||||
import { executeInvoiceAction } from "../../execute/executeInvoiceAction";
|
|
||||||
import { executeStripeSubAction } from "../../execute/executeStripeSubAction";
|
|
||||||
import { executeStripeSubscriptionUncancel } from "../../execute/executeStripeSubscriptionActions/handleStripeSubscriptionUncancel";
|
|
||||||
import type { UpdateSubscriptionContext } from "../fetch/updateSubscriptionContextSchema";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Executes the subscription update flow in stripe & autumn using context & plan.
|
|
||||||
*
|
|
||||||
* @param ctx - The context object.
|
|
||||||
* @param params - The parameters for the subscription update.
|
|
||||||
* @param updateSubscriptionContext - The context object for the subscription update.
|
|
||||||
* @param subscriptionUpdatePlan - The plan for the subscription update.
|
|
||||||
* @returns A promise that resolves when the subscription update is complete.
|
|
||||||
*/
|
|
||||||
export const executeSubscriptionUpdate = async ({
|
|
||||||
ctx,
|
|
||||||
params,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
subscriptionUpdatePlan,
|
|
||||||
}: {
|
|
||||||
ctx: AutumnContext;
|
|
||||||
params: SubscriptionUpdateV0Params;
|
|
||||||
updateSubscriptionContext: UpdateSubscriptionContext;
|
|
||||||
subscriptionUpdatePlan: SubscriptionUpdatePlan;
|
|
||||||
}) => {
|
|
||||||
const { logger } = ctx;
|
|
||||||
const { customerProduct, stripeSubscription } = updateSubscriptionContext;
|
|
||||||
const {
|
|
||||||
ongoingCusProductAction,
|
|
||||||
stripeSubscriptionAction,
|
|
||||||
quantityUpdateDetails,
|
|
||||||
invoiceAction,
|
|
||||||
shouldUncancelSubscription,
|
|
||||||
} = subscriptionUpdatePlan;
|
|
||||||
|
|
||||||
if (shouldUncancelSubscription) {
|
|
||||||
await executeStripeSubscriptionUncancel({
|
|
||||||
ctx,
|
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
|
||||||
customerProduct,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Executing Stripe subscription action");
|
|
||||||
await executeStripeSubAction({
|
|
||||||
ctx,
|
|
||||||
stripeSubAction: stripeSubscriptionAction,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (invoiceAction) {
|
|
||||||
logger.info("Executing invoice action");
|
|
||||||
await executeInvoiceAction({
|
|
||||||
ctx,
|
|
||||||
invoiceAction,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
logger.info("No invoice action required");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Executing customer product actions");
|
|
||||||
await executeCusProductActions({
|
|
||||||
ctx,
|
|
||||||
ongoingCusProductAction,
|
|
||||||
newCusProducts: [],
|
|
||||||
quantityUpdateDetails,
|
|
||||||
updatedFeatureOptions: params.options || [],
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info("Successfully completed subscription update");
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
import {
|
import {
|
||||||
type FullCustomer,
|
type FullCustomer,
|
||||||
isCusProductOnEntity,
|
isCusProductOnEntity,
|
||||||
type SubscriptionUpdateV0Params,
|
type UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
|
|
||||||
export const fetchTargetCusProductForUpdate = ({
|
export const fetchTargetCusProductForUpdate = ({
|
||||||
params,
|
params,
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
}: {
|
}: {
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
fullCustomer: FullCustomer;
|
fullCustomer: FullCustomer;
|
||||||
}) => {
|
}) => {
|
||||||
const cusProducts = fullCustomer.customer_products;
|
const cusProducts = fullCustomer.customer_products;
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
|
cusProductToProduct,
|
||||||
InternalError,
|
InternalError,
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
secondsToMs,
|
secondsToMs,
|
||||||
} from "@shared/index";
|
type UpdateSubscriptionV0Params,
|
||||||
|
} from "@autumn/shared";
|
||||||
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
|
import { fetchStripeCustomerForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeCustomerForBilling";
|
||||||
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
|
import { fetchStripeSubscriptionForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionForBilling";
|
||||||
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
|
import { fetchStripeSubscriptionScheduleForBilling } from "@/internal/billing/v2/providers/stripe/fetch/fetchStripeSubscriptionScheduleForBilling";
|
||||||
import { CusService } from "../../../../customers/CusService";
|
import { CusService } from "../../../../customers/CusService";
|
||||||
|
import type { UpdateSubscriptionBillingContext } from "../../billingContext";
|
||||||
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
|
import { parseFeatureQuantitiesParams } from "../../utils/parseFeatureQuantitiesParams";
|
||||||
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
|
import { fetchTargetCusProductForUpdate } from "./fetchTargetCusProductForUpdate";
|
||||||
import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchema";
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the context for updating a subscription
|
* Fetch the context for updating a subscription
|
||||||
@@ -18,13 +19,13 @@ import type { UpdateSubscriptionContext } from "./updateSubscriptionContextSchem
|
|||||||
* @param body - The body of the request
|
* @param body - The body of the request
|
||||||
* @returns The update subscription context
|
* @returns The update subscription context
|
||||||
*/
|
*/
|
||||||
export const fetchApiSubscriptionUpdateContext = async ({
|
export const fetchUpdateSubscriptionBillingContext = async ({
|
||||||
ctx,
|
ctx,
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
ctx: AutumnContext;
|
ctx: AutumnContext;
|
||||||
params: SubscriptionUpdateV0Params;
|
params: UpdateSubscriptionV0Params;
|
||||||
}): Promise<UpdateSubscriptionContext> => {
|
}): Promise<UpdateSubscriptionBillingContext> => {
|
||||||
const { db, org, env, features } = ctx;
|
const { db, org, env, features } = ctx;
|
||||||
const { customer_id: customerId, product_id: productId } = params;
|
const { customer_id: customerId, product_id: productId } = params;
|
||||||
|
|
||||||
@@ -49,6 +50,10 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fullProduct = cusProductToProduct({
|
||||||
|
cusProduct: targetCustomerProduct,
|
||||||
|
});
|
||||||
|
|
||||||
const stripeSubscription = await fetchStripeSubscriptionForBilling({
|
const stripeSubscription = await fetchStripeSubscriptionForBilling({
|
||||||
ctx,
|
ctx,
|
||||||
fullCus: fullCustomer,
|
fullCus: fullCustomer,
|
||||||
@@ -91,16 +96,26 @@ export const fetchApiSubscriptionUpdateContext = async ({
|
|||||||
stripeSubscription?.billing_cycle_anchor,
|
stripeSubscription?.billing_cycle_anchor,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Invoice mode
|
||||||
|
const invoiceMode =
|
||||||
|
params?.invoice === true
|
||||||
|
? {
|
||||||
|
finalizeInvoice: params.finalize_invoice === true,
|
||||||
|
enableProductImmediately: params.enable_product_immediately !== false,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fullCustomer,
|
fullCustomer,
|
||||||
fullProducts: [],
|
fullProducts: [fullProduct],
|
||||||
customerProduct: targetCustomerProduct,
|
customerProduct: targetCustomerProduct,
|
||||||
stripeSubscription,
|
stripeSubscription,
|
||||||
stripeSubscriptionSchedule,
|
stripeSubscriptionSchedule,
|
||||||
stripeCustomer,
|
stripeCustomer,
|
||||||
paymentMethod,
|
paymentMethod,
|
||||||
testClockFrozenTime,
|
|
||||||
currentEpochMs,
|
currentEpochMs,
|
||||||
billingCycleAnchorMs,
|
billingCycleAnchorMs,
|
||||||
|
invoiceMode,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import type { FullCusProduct, FullCustomer, FullProduct } from "@shared/index";
|
|
||||||
import type Stripe from "stripe";
|
|
||||||
|
|
||||||
export type UpdateSubscriptionContext = {
|
|
||||||
fullCustomer: FullCustomer;
|
|
||||||
fullProducts: FullProduct[];
|
|
||||||
customerProduct: FullCusProduct;
|
|
||||||
stripeSubscription?: Stripe.Subscription;
|
|
||||||
stripeSubscriptionSchedule?: Stripe.SubscriptionSchedule;
|
|
||||||
stripeCustomer: Stripe.Customer;
|
|
||||||
paymentMethod?: Stripe.PaymentMethod;
|
|
||||||
testClockFrozenTime?: number;
|
|
||||||
currentEpochMs: number;
|
|
||||||
billingCycleAnchorMs?: number;
|
|
||||||
};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { SubscriptionUpdateV0ParamsSchema } from "@autumn/shared";
|
|
||||||
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
|
||||||
import { computeSubscriptionUpdatePlan } from "../subscriptionUpdate/compute/computeSubscriptionUpdatePlan";
|
|
||||||
import { fetchApiSubscriptionUpdateContext } from "../subscriptionUpdate/fetch/fetchApiSubscriptionUpdateContext";
|
|
||||||
|
|
||||||
export const handleSubscriptionUpdatePreview = createRoute({
|
|
||||||
body: SubscriptionUpdateV0ParamsSchema,
|
|
||||||
handler: async (c) => {
|
|
||||||
const ctx = c.get("ctx");
|
|
||||||
const body = c.req.valid("json");
|
|
||||||
|
|
||||||
const updateSubscriptionContext = await fetchApiSubscriptionUpdateContext({
|
|
||||||
ctx,
|
|
||||||
params: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
const subscriptionUpdatePlan = await computeSubscriptionUpdatePlan({
|
|
||||||
ctx,
|
|
||||||
updateSubscriptionContext,
|
|
||||||
params: body,
|
|
||||||
});
|
|
||||||
|
|
||||||
return c.json(subscriptionUpdatePlan, 200);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { UpdateSubscriptionV0ParamsSchema } from "@autumn/shared";
|
||||||
|
import { evaluateStripeBillingPlan } from "@/internal/billing/v2/providers/stripe/actionBuilders/evaluateStripeBillingPlan";
|
||||||
|
import { createRoute } from "../../../../honoMiddlewares/routeHandler";
|
||||||
|
import { computeSubscriptionUpdatePlan } from "./compute/computeSubscriptionUpdatePlan";
|
||||||
|
import { fetchUpdateSubscriptionBillingContext } from "./fetch/fetchUpdateSubscriptionBillingContext";
|
||||||
|
|
||||||
|
export const handleUpdateSubscriptionPreview = createRoute({
|
||||||
|
body: UpdateSubscriptionV0ParamsSchema,
|
||||||
|
handler: async (c) => {
|
||||||
|
const ctx = c.get("ctx");
|
||||||
|
const body = c.req.valid("json");
|
||||||
|
|
||||||
|
const updateSubscriptionBillingContext =
|
||||||
|
await fetchUpdateSubscriptionBillingContext({
|
||||||
|
ctx,
|
||||||
|
params: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const autumnBillingPlan = await computeSubscriptionUpdatePlan({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionBillingContext,
|
||||||
|
params: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
const stripeBillingPlan = evaluateStripeBillingPlan({
|
||||||
|
ctx,
|
||||||
|
billingContext: updateSubscriptionBillingContext,
|
||||||
|
autumnBillingPlan,
|
||||||
|
});
|
||||||
|
|
||||||
|
return c.json(
|
||||||
|
{
|
||||||
|
autumn: autumnBillingPlan,
|
||||||
|
stripe: stripeBillingPlan,
|
||||||
|
},
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
PriceSchema,
|
PriceSchema,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import type { BillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||||
import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels";
|
import { FullCusProductSchema } from "../../../../../../shared/models/cusProductModels/cusProductModels";
|
||||||
|
|
||||||
export const FreeTrialPlanSchema = z.object({
|
export const FreeTrialPlanSchema = z.object({
|
||||||
@@ -15,13 +17,6 @@ export const FreeTrialPlanSchema = z.object({
|
|||||||
|
|
||||||
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>;
|
export type FreeTrialPlan = z.infer<typeof FreeTrialPlanSchema>;
|
||||||
|
|
||||||
export const InvoiceModeSchema = z.object({
|
|
||||||
finalizeInvoice: z.boolean().default(false),
|
|
||||||
enableProductImmediately: z.boolean().default(true),
|
|
||||||
});
|
|
||||||
|
|
||||||
export type InvoiceMode = z.infer<typeof InvoiceModeSchema>;
|
|
||||||
|
|
||||||
export const UpdateCustomerEntitlementSchema = z.object({
|
export const UpdateCustomerEntitlementSchema = z.object({
|
||||||
customerEntitlementId: z.string(),
|
customerEntitlementId: z.string(),
|
||||||
balanceChange: z.number(),
|
balanceChange: z.number(),
|
||||||
@@ -50,5 +45,6 @@ export type AutumnBillingPlan = z.infer<typeof AutumnBillingPlanSchema>;
|
|||||||
export type DeferredAutumnBillingPlanData = {
|
export type DeferredAutumnBillingPlanData = {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
env: AppEnv;
|
env: AppEnv;
|
||||||
autumnBillingPlan: AutumnBillingPlan;
|
billingPlan: BillingPlan;
|
||||||
|
billingContext: BillingContext;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import {
|
|||||||
StripeBillingPlanSchema,
|
StripeBillingPlanSchema,
|
||||||
type StripeInvoiceAction,
|
type StripeInvoiceAction,
|
||||||
StripeInvoiceActionSchema,
|
StripeInvoiceActionSchema,
|
||||||
|
type StripeInvoiceItemsAction,
|
||||||
|
StripeInvoiceItemsActionSchema,
|
||||||
type StripeInvoiceMetadata,
|
type StripeInvoiceMetadata,
|
||||||
type StripeSubscriptionAction,
|
type StripeSubscriptionAction,
|
||||||
StripeSubscriptionActionSchema,
|
StripeSubscriptionActionSchema,
|
||||||
@@ -24,6 +26,7 @@ export {
|
|||||||
InvoiceModeSchema,
|
InvoiceModeSchema,
|
||||||
StripeBillingPlanSchema,
|
StripeBillingPlanSchema,
|
||||||
StripeInvoiceActionSchema,
|
StripeInvoiceActionSchema,
|
||||||
|
StripeInvoiceItemsActionSchema,
|
||||||
StripeSubscriptionActionSchema,
|
StripeSubscriptionActionSchema,
|
||||||
StripeSubscriptionScheduleActionSchema,
|
StripeSubscriptionScheduleActionSchema,
|
||||||
type AutumnBillingPlan,
|
type AutumnBillingPlan,
|
||||||
@@ -32,6 +35,7 @@ export {
|
|||||||
type InvoiceMode,
|
type InvoiceMode,
|
||||||
type StripeBillingPlan,
|
type StripeBillingPlan,
|
||||||
type StripeInvoiceAction,
|
type StripeInvoiceAction,
|
||||||
|
type StripeInvoiceItemsAction,
|
||||||
type StripeInvoiceMetadata,
|
type StripeInvoiceMetadata,
|
||||||
type StripeSubscriptionAction,
|
type StripeSubscriptionAction,
|
||||||
type StripeSubscriptionScheduleAction,
|
type StripeSubscriptionScheduleAction,
|
||||||
|
|||||||
@@ -1,22 +1,28 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import {
|
import {
|
||||||
StripeInvoiceActionSchema,
|
|
||||||
type StripeInvoiceAction,
|
type StripeInvoiceAction,
|
||||||
|
StripeInvoiceActionSchema,
|
||||||
} from "./stripeInvoiceAction";
|
} from "./stripeInvoiceAction";
|
||||||
import {
|
import {
|
||||||
StripeSubscriptionActionSchema,
|
type StripeInvoiceItemsAction,
|
||||||
|
StripeInvoiceItemsActionSchema,
|
||||||
|
} from "./stripeInvoiceItemsAction";
|
||||||
|
import {
|
||||||
type StripeSubscriptionAction,
|
type StripeSubscriptionAction,
|
||||||
|
StripeSubscriptionActionSchema,
|
||||||
} from "./stripeSubscriptionAction";
|
} from "./stripeSubscriptionAction";
|
||||||
import {
|
import {
|
||||||
StripeSubscriptionScheduleActionSchema,
|
|
||||||
type StripeSubscriptionScheduleAction,
|
type StripeSubscriptionScheduleAction,
|
||||||
|
StripeSubscriptionScheduleActionSchema,
|
||||||
} from "./stripeSubscriptionScheduleAction";
|
} from "./stripeSubscriptionScheduleAction";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
StripeInvoiceActionSchema,
|
StripeInvoiceActionSchema,
|
||||||
|
StripeInvoiceItemsActionSchema,
|
||||||
StripeSubscriptionActionSchema,
|
StripeSubscriptionActionSchema,
|
||||||
StripeSubscriptionScheduleActionSchema,
|
StripeSubscriptionScheduleActionSchema,
|
||||||
type StripeInvoiceAction,
|
type StripeInvoiceAction,
|
||||||
|
type StripeInvoiceItemsAction,
|
||||||
type StripeSubscriptionAction,
|
type StripeSubscriptionAction,
|
||||||
type StripeSubscriptionScheduleAction,
|
type StripeSubscriptionScheduleAction,
|
||||||
};
|
};
|
||||||
@@ -25,6 +31,7 @@ export const StripeBillingPlanSchema = z.object({
|
|||||||
subscriptionAction: StripeSubscriptionActionSchema.optional(),
|
subscriptionAction: StripeSubscriptionActionSchema.optional(),
|
||||||
subscriptionScheduleAction: StripeSubscriptionScheduleActionSchema.optional(),
|
subscriptionScheduleAction: StripeSubscriptionScheduleActionSchema.optional(),
|
||||||
invoiceAction: StripeInvoiceActionSchema.optional(),
|
invoiceAction: StripeInvoiceActionSchema.optional(),
|
||||||
|
invoiceItemsAction: StripeInvoiceItemsActionSchema.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
|
export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
|
||||||
@@ -32,4 +39,3 @@ export type StripeBillingPlan = z.infer<typeof StripeBillingPlanSchema>;
|
|||||||
export type StripeInvoiceMetadata = {
|
export type StripeInvoiceMetadata = {
|
||||||
autumn_metadata_id: string;
|
autumn_metadata_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { InvoiceModeSchema } from "../autumnBillingPlan";
|
|
||||||
|
|
||||||
export const StripeInvoiceActionSchema = z.object({
|
export const StripeInvoiceActionSchema = z.object({
|
||||||
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
|
addLineParams: z.custom<import("stripe").Stripe.InvoiceAddLinesParams>(),
|
||||||
invoiceMode: InvoiceModeSchema.optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export type StripeInvoiceAction = z.infer<typeof StripeInvoiceActionSchema>;
|
export type StripeInvoiceAction = z.infer<typeof StripeInvoiceActionSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
|
export const StripeInvoiceItemsActionSchema = z.object({
|
||||||
|
createInvoiceItems: z.array(
|
||||||
|
z.custom<import("stripe").Stripe.InvoiceItemCreateParams>(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type StripeInvoiceItemsAction = z.infer<
|
||||||
|
typeof StripeInvoiceItemsActionSchema
|
||||||
|
>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type Stripe from "stripe";
|
||||||
|
|
||||||
|
export interface StripeBillingPlanResult {
|
||||||
|
deferred?: boolean;
|
||||||
|
stripeInvoice?: Stripe.Invoice;
|
||||||
|
stripeSubscription?: Stripe.Subscription;
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import type { AutumnBillingPlan } from "@/internal/billing/v2/types/billingPlan";
|
||||||
|
|
||||||
|
export const autumnBillingPlanToFinalFullCustomer = ({
|
||||||
|
billingContext,
|
||||||
|
autumnBillingPlan,
|
||||||
|
}: {
|
||||||
|
billingContext: BillingContext;
|
||||||
|
autumnBillingPlan: AutumnBillingPlan;
|
||||||
|
}) => {
|
||||||
|
const finalFullCustomer = structuredClone(billingContext.fullCustomer);
|
||||||
|
|
||||||
|
// 1. Update full customer with new customer products
|
||||||
|
finalFullCustomer.customer_products = [
|
||||||
|
...finalFullCustomer.customer_products,
|
||||||
|
...autumnBillingPlan.insertCustomerProducts,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Update customer product
|
||||||
|
for (let i = 0; i < finalFullCustomer.customer_products.length; i++) {
|
||||||
|
const customerProduct = finalFullCustomer.customer_products[i];
|
||||||
|
if (customerProduct.id === autumnBillingPlan.updateCustomerProduct?.id) {
|
||||||
|
finalFullCustomer.customer_products[i] =
|
||||||
|
autumnBillingPlan.updateCustomerProduct;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update full customer with updated customer entitlements
|
||||||
|
if (autumnBillingPlan.updateCustomerEntitlements) {
|
||||||
|
for (const update of autumnBillingPlan.updateCustomerEntitlements) {
|
||||||
|
for (const customerProduct of finalFullCustomer.customer_products) {
|
||||||
|
for (const customerEntitlement of customerProduct.customer_entitlements) {
|
||||||
|
if (customerEntitlement.id === update.customerEntitlementId) {
|
||||||
|
customerEntitlement.balance =
|
||||||
|
(customerEntitlement.balance ?? 0) + update.balanceChange;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return finalFullCustomer;
|
||||||
|
};
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
import type { FullCusProduct } from "@shared/models/cusProductModels/cusProductModels";
|
|
||||||
import {
|
|
||||||
isCustomerProductOnStripeSubscription,
|
|
||||||
isCustomerProductOnStripeSubscriptionSchedule,
|
|
||||||
} from "@shared/utils";
|
|
||||||
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets the final customer product state after applying add/remove operations.
|
|
||||||
* Filters to only products on the current subscription.
|
|
||||||
*/
|
|
||||||
export const getFinalCustomerProductsState = ({
|
|
||||||
billingContext,
|
|
||||||
addCustomerProducts = [],
|
|
||||||
removeCustomerProducts = [],
|
|
||||||
}: {
|
|
||||||
billingContext: BillingContext;
|
|
||||||
addCustomerProducts?: FullCusProduct[];
|
|
||||||
removeCustomerProducts?: FullCusProduct[];
|
|
||||||
}): FullCusProduct[] => {
|
|
||||||
const { fullCustomer, stripeSubscription, stripeSubscriptionSchedule } =
|
|
||||||
billingContext;
|
|
||||||
|
|
||||||
// Start with existing products on this subscription
|
|
||||||
let customerProducts = stripeSubscription
|
|
||||||
? fullCustomer.customer_products.filter(
|
|
||||||
(cp) =>
|
|
||||||
isCustomerProductOnStripeSubscription({
|
|
||||||
customerProduct: cp,
|
|
||||||
stripeSubscriptionId: stripeSubscription.id,
|
|
||||||
}) ||
|
|
||||||
isCustomerProductOnStripeSubscriptionSchedule({
|
|
||||||
customerProduct: cp,
|
|
||||||
stripeSubscriptionScheduleId: stripeSubscriptionSchedule?.id ?? "",
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
// Remove specified products
|
|
||||||
customerProducts = customerProducts.filter(
|
|
||||||
(cp) =>
|
|
||||||
!removeCustomerProducts.some((cpToRemove) => cpToRemove.id === cp.id),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add new products
|
|
||||||
customerProducts = [...customerProducts, ...addCustomerProducts];
|
|
||||||
|
|
||||||
return customerProducts;
|
|
||||||
};
|
|
||||||
@@ -7,9 +7,14 @@ import {
|
|||||||
import { and, eq } from "drizzle-orm";
|
import { and, eq } from "drizzle-orm";
|
||||||
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
import type { DrizzleCli } from "@/db/initDrizzle.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MetadataService handles CRUD operations for the metadata table.
|
||||||
|
*/
|
||||||
export class MetadataService {
|
export class MetadataService {
|
||||||
static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) {
|
static async insert({ db, data }: { db: DrizzleCli; data: MetadataInsert }) {
|
||||||
await db.insert(metadata).values(data);
|
const insertedMetadata = await db.insert(metadata).values(data).returning();
|
||||||
|
|
||||||
|
return insertedMetadata[0] as Metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async get({ db, id }: { db: DrizzleCli; id: string }) {
|
static async get({ db, id }: { db: DrizzleCli; id: string }) {
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { generateId, InternalError, MetadataType } from "@autumn/shared";
|
||||||
|
import type Stripe from "stripe";
|
||||||
|
import { createStripeCli } from "@/external/connect/createStripeCli";
|
||||||
|
import type { AutumnContext } from "@/honoUtils/HonoEnv";
|
||||||
|
import type { BillingContext } from "@/internal/billing/v2/billingContext";
|
||||||
|
import type {
|
||||||
|
BillingPlan,
|
||||||
|
DeferredAutumnBillingPlanData,
|
||||||
|
} from "@/internal/billing/v2/types/billingPlan";
|
||||||
|
import { MetadataService } from "../MetadataService";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates metadata from a billing plan and optionally links it to a Stripe invoice.
|
||||||
|
*/
|
||||||
|
export const insertMetadataFromBillingPlan = async ({
|
||||||
|
ctx,
|
||||||
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
|
enableProductAfterInvoice,
|
||||||
|
invoiceActionRequired,
|
||||||
|
stripeInvoice,
|
||||||
|
}: {
|
||||||
|
ctx: AutumnContext;
|
||||||
|
billingPlan: BillingPlan;
|
||||||
|
billingContext: BillingContext;
|
||||||
|
enableProductAfterInvoice?: boolean;
|
||||||
|
invoiceActionRequired?: boolean;
|
||||||
|
stripeInvoice?: Stripe.Invoice;
|
||||||
|
}) => {
|
||||||
|
const id = generateId("meta");
|
||||||
|
|
||||||
|
const type = enableProductAfterInvoice
|
||||||
|
? MetadataType.InvoiceCheckoutV2
|
||||||
|
: invoiceActionRequired
|
||||||
|
? MetadataType.InvoiceActionRequiredV2
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
orgId: ctx.org.id,
|
||||||
|
env: ctx.env,
|
||||||
|
billingPlan,
|
||||||
|
billingContext,
|
||||||
|
} satisfies DeferredAutumnBillingPlanData;
|
||||||
|
|
||||||
|
const metadata = await MetadataService.insert({
|
||||||
|
db: ctx.db,
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
stripe_invoice_id: stripeInvoice?.id,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!metadata) {
|
||||||
|
throw new InternalError({
|
||||||
|
message: "Failed to insert metadata from billing plan",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If stripeInvoice, update stripeInvoice with metadata id
|
||||||
|
if (stripeInvoice) {
|
||||||
|
const stripeCli = createStripeCli({ org: ctx.org, env: ctx.env });
|
||||||
|
await stripeCli.invoices.update(stripeInvoice.id, {
|
||||||
|
metadata: {
|
||||||
|
autumn_metadata_id: metadata.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return metadata;
|
||||||
|
};
|
||||||
@@ -41,7 +41,6 @@ const parseFreeTrials = ({
|
|||||||
return product;
|
return product;
|
||||||
};
|
};
|
||||||
|
|
||||||
// biome-ignore lint/complexity/noStaticOnlyClass: no thanks m8
|
|
||||||
export class ProductService {
|
export class ProductService {
|
||||||
static async getByFeature({
|
static async getByFeature({
|
||||||
db,
|
db,
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ export const checkForMisingBalance = async ({
|
|||||||
const feature = cusEnt.entitlement.feature;
|
const feature = cusEnt.entitlement.feature;
|
||||||
|
|
||||||
// 1. If unlimited or boolean feature, skip
|
// 1. If unlimited or boolean feature, skip
|
||||||
if (isUnlimitedCusEnt({ cusEnt }) || isBooleanCusEnt({ cusEnt })) continue;
|
if (isUnlimitedCusEnt(cusEnt) || isBooleanCusEnt({ cusEnt })) continue;
|
||||||
|
|
||||||
if (isContUseFeature({ feature })) continue;
|
if (isContUseFeature({ feature })) continue;
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,17 @@
|
|||||||
import { beforeAll, describe, expect, test } from "bun:test";
|
import { beforeAll, describe, expect, test } from "bun:test";
|
||||||
import { type ApiCustomer, ApiVersion } from "@autumn/shared";
|
import {
|
||||||
|
type ApiCustomer,
|
||||||
|
ApiVersion,
|
||||||
|
findPriceByFeatureId,
|
||||||
|
type Price,
|
||||||
|
priceToLineAmount,
|
||||||
|
} from "@autumn/shared";
|
||||||
|
import { expectLatestInvoiceCorrect } from "@tests/billing/utils/expectLatestInvoiceCorrect";
|
||||||
import { TestFeature } from "@tests/setup/v2Features.js";
|
import { TestFeature } from "@tests/setup/v2Features.js";
|
||||||
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
import ctx from "@tests/utils/testInitUtils/createTestContext.js";
|
||||||
import chalk from "chalk";
|
import chalk from "chalk";
|
||||||
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
import { AutumnInt } from "@/external/autumn/autumnCli.js";
|
||||||
|
import { ProductService } from "@/internal/products/ProductService";
|
||||||
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
import { constructPrepaidItem } from "@/utils/scriptUtils/constructItem.js";
|
||||||
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
import { constructRawProduct } from "@/utils/scriptUtils/createTestProducts.js";
|
||||||
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
import { initCustomerV3 } from "@/utils/scriptUtils/testUtils/initCustomerV3.js";
|
||||||
@@ -30,17 +38,19 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
|||||||
const customerId = "sub-update-basic";
|
const customerId = "sub-update-basic";
|
||||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
const prepaidProduct = constructRawProduct({
|
const prepaidMessages = constructPrepaidItem({
|
||||||
id: "prepaid_messages",
|
|
||||||
items: [
|
|
||||||
constructPrepaidItem({
|
|
||||||
featureId: TestFeature.Messages,
|
featureId: TestFeature.Messages,
|
||||||
billingUnits,
|
billingUnits,
|
||||||
price: pricePerUnit,
|
price: pricePerUnit,
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const prepaidProduct = constructRawProduct({
|
||||||
|
id: "prepaid_messages",
|
||||||
|
items: [prepaidMessages],
|
||||||
|
});
|
||||||
|
|
||||||
|
let prepaidMessagesPrice: Price | undefined;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initCustomerV3({
|
await initCustomerV3({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -55,6 +65,18 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
|||||||
prefix: customerId,
|
prefix: customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fullProduct = await ProductService.getFull({
|
||||||
|
db: ctx.db,
|
||||||
|
idOrInternalId: prepaidProduct.id,
|
||||||
|
orgId: ctx.org.id,
|
||||||
|
env: ctx.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
prepaidMessagesPrice = findPriceByFeatureId({
|
||||||
|
prices: fullProduct.prices,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
});
|
||||||
|
|
||||||
await autumnV1.attach({
|
await autumnV1.attach({
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
product_id: prepaidProduct.id,
|
product_id: prepaidProduct.id,
|
||||||
@@ -85,6 +107,16 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
|||||||
// Should have 240 messages (20 units × 12 billing_units)
|
// Should have 240 messages (20 units × 12 billing_units)
|
||||||
expect(balance?.purchased_balance).toBe(240);
|
expect(balance?.purchased_balance).toBe(240);
|
||||||
expect(balance?.current_balance).toBe(240);
|
expect(balance?.current_balance).toBe(240);
|
||||||
|
|
||||||
|
// Expect invoices to be created
|
||||||
|
expectLatestInvoiceCorrect({
|
||||||
|
customer,
|
||||||
|
productId: prepaidProduct.id,
|
||||||
|
amount: priceToLineAmount({
|
||||||
|
price: prepaidMessagesPrice!,
|
||||||
|
overage: 10 * billingUnits,
|
||||||
|
}),
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should downgrade quantity from 20 to 5 units", async () => {
|
test("should downgrade quantity from 20 to 5 units", async () => {
|
||||||
@@ -100,15 +132,29 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
|||||||
});
|
});
|
||||||
|
|
||||||
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
|
const customer = await autumnV1.customers.get<ApiCustomer>(customerId);
|
||||||
|
|
||||||
const balance = customer.balances?.[TestFeature.Messages];
|
const balance = customer.balances?.[TestFeature.Messages];
|
||||||
|
|
||||||
// Should have 60 messages (5 units × 12 billing_units)
|
// Should have 60 messages (5 units × 12 billing_units)
|
||||||
expect(balance?.purchased_balance).toBe(60);
|
expect(balance?.current_balance).toBe(60);
|
||||||
|
|
||||||
|
// Expect credit invoice for downgrade (20 -> 5 = -15 units)
|
||||||
|
const expectedAmount = priceToLineAmount({
|
||||||
|
price: prepaidMessagesPrice!,
|
||||||
|
overage: -15 * billingUnits,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectLatestInvoiceCorrect({
|
||||||
|
customer,
|
||||||
|
productId: prepaidProduct.id,
|
||||||
|
amount: expectedAmount,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should update to same quantity (no-op)", async () => {
|
test("should update to same quantity (no-op)", async () => {
|
||||||
const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
|
const beforeUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
|
||||||
const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages];
|
const beforeBalance = beforeUpdate.balances?.[TestFeature.Messages];
|
||||||
|
const beforeInvoiceCount = beforeUpdate.invoices?.length ?? 0;
|
||||||
|
|
||||||
await autumnV1.subscriptionUpdate({
|
await autumnV1.subscriptionUpdate({
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
@@ -123,10 +169,12 @@ describe(`${chalk.yellowBright("subscription-update: basic quantity updates")}`,
|
|||||||
|
|
||||||
const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
|
const afterUpdate = await autumnV1.customers.get<ApiCustomer>(customerId);
|
||||||
const afterBalance = afterUpdate.balances?.[TestFeature.Messages];
|
const afterBalance = afterUpdate.balances?.[TestFeature.Messages];
|
||||||
|
const afterInvoiceCount = afterUpdate.invoices?.length ?? 0;
|
||||||
|
|
||||||
expect(afterBalance?.purchased_balance).toBe(
|
expect(afterBalance?.current_balance).toBe(beforeBalance?.current_balance);
|
||||||
beforeBalance?.purchased_balance,
|
|
||||||
);
|
// No-op should not create a new invoice
|
||||||
|
expect(afterInvoiceCount).toBe(beforeInvoiceCount);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -134,22 +182,28 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
|||||||
const customerId = "sub-update-multi";
|
const customerId = "sub-update-multi";
|
||||||
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
const autumnV1 = new AutumnInt({ version: ApiVersion.V1_2 });
|
||||||
|
|
||||||
|
const messagesBillingUnits = 10;
|
||||||
|
const wordsBillingUnits = 100;
|
||||||
|
|
||||||
const multiFeatureProduct = constructRawProduct({
|
const multiFeatureProduct = constructRawProduct({
|
||||||
id: "multi_feature_product",
|
id: "multi_feature_product",
|
||||||
items: [
|
items: [
|
||||||
constructPrepaidItem({
|
constructPrepaidItem({
|
||||||
featureId: TestFeature.Messages,
|
featureId: TestFeature.Messages,
|
||||||
billingUnits: 10,
|
billingUnits: messagesBillingUnits,
|
||||||
price: 5,
|
price: 5,
|
||||||
}),
|
}),
|
||||||
constructPrepaidItem({
|
constructPrepaidItem({
|
||||||
featureId: TestFeature.Words,
|
featureId: TestFeature.Words,
|
||||||
billingUnits: 100,
|
billingUnits: wordsBillingUnits,
|
||||||
price: 10,
|
price: 10,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let messagesPrice: Price | undefined;
|
||||||
|
let wordsPrice: Price | undefined;
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await initCustomerV3({
|
await initCustomerV3({
|
||||||
ctx,
|
ctx,
|
||||||
@@ -164,17 +218,34 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
|||||||
prefix: customerId,
|
prefix: customerId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const fullProduct = await ProductService.getFull({
|
||||||
|
db: ctx.db,
|
||||||
|
idOrInternalId: multiFeatureProduct.id,
|
||||||
|
orgId: ctx.org.id,
|
||||||
|
env: ctx.env,
|
||||||
|
});
|
||||||
|
|
||||||
|
messagesPrice = findPriceByFeatureId({
|
||||||
|
prices: fullProduct.prices,
|
||||||
|
featureId: TestFeature.Messages,
|
||||||
|
});
|
||||||
|
|
||||||
|
wordsPrice = findPriceByFeatureId({
|
||||||
|
prices: fullProduct.prices,
|
||||||
|
featureId: TestFeature.Words,
|
||||||
|
});
|
||||||
|
|
||||||
await autumnV1.attach({
|
await autumnV1.attach({
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
product_id: multiFeatureProduct.id,
|
product_id: multiFeatureProduct.id,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
feature_id: TestFeature.Messages,
|
feature_id: TestFeature.Messages,
|
||||||
quantity: 5 * 10, // 50 messages
|
quantity: 5 * messagesBillingUnits, // 50 messages
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature_id: TestFeature.Words,
|
feature_id: TestFeature.Words,
|
||||||
quantity: 2 * 100, // 200 words
|
quantity: 2 * wordsBillingUnits, // 200 words
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -187,11 +258,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
|||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
feature_id: TestFeature.Messages,
|
feature_id: TestFeature.Messages,
|
||||||
quantity: 10 * 10, // 100 messages
|
quantity: 10 * messagesBillingUnits, // 100 messages
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature_id: TestFeature.Words,
|
feature_id: TestFeature.Words,
|
||||||
quantity: 5 * 100, // 500 words
|
quantity: 5 * wordsBillingUnits, // 500 words
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -203,6 +274,23 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
|||||||
|
|
||||||
const wordsBalance = customer.balances?.[TestFeature.Words];
|
const wordsBalance = customer.balances?.[TestFeature.Words];
|
||||||
expect(wordsBalance?.purchased_balance).toBe(500);
|
expect(wordsBalance?.purchased_balance).toBe(500);
|
||||||
|
|
||||||
|
// Invoice total: Messages (5->10 units = +5) + Words (2->5 units = +3)
|
||||||
|
const expectedAmount =
|
||||||
|
priceToLineAmount({
|
||||||
|
price: messagesPrice!,
|
||||||
|
overage: 5 * messagesBillingUnits,
|
||||||
|
}) +
|
||||||
|
priceToLineAmount({
|
||||||
|
price: wordsPrice!,
|
||||||
|
overage: 3 * wordsBillingUnits,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectLatestInvoiceCorrect({
|
||||||
|
customer,
|
||||||
|
productId: multiFeatureProduct.id,
|
||||||
|
amount: expectedAmount,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test("should update only one feature while keeping the other unchanged", async () => {
|
test("should update only one feature while keeping the other unchanged", async () => {
|
||||||
@@ -212,11 +300,11 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
|||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
feature_id: TestFeature.Messages,
|
feature_id: TestFeature.Messages,
|
||||||
quantity: 15 * 10, // 150 messages (changed)
|
quantity: 15 * messagesBillingUnits, // 150 messages (changed)
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
feature_id: TestFeature.Words,
|
feature_id: TestFeature.Words,
|
||||||
quantity: 5 * 100, // 500 words (unchanged)
|
quantity: 5 * wordsBillingUnits, // 500 words (unchanged)
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
@@ -228,5 +316,25 @@ describe(`${chalk.yellowBright("subscription-update: multiple features")}`, () =
|
|||||||
|
|
||||||
const wordsBalance = customer.balances?.[TestFeature.Words];
|
const wordsBalance = customer.balances?.[TestFeature.Words];
|
||||||
expect(wordsBalance?.purchased_balance).toBe(500);
|
expect(wordsBalance?.purchased_balance).toBe(500);
|
||||||
|
|
||||||
|
// Invoice total: Messages (10->15 units = +5), Words unchanged
|
||||||
|
const expectedAmount = priceToLineAmount({
|
||||||
|
price: messagesPrice!,
|
||||||
|
overage: 5 * messagesBillingUnits,
|
||||||
|
});
|
||||||
|
|
||||||
|
expectLatestInvoiceCorrect({
|
||||||
|
customer,
|
||||||
|
productId: multiFeatureProduct.id,
|
||||||
|
amount: expectedAmount,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Verify Stripe invoice has only 2 line items
|
||||||
|
const stripeInvoices = await ctx.stripeCli.invoices.list({
|
||||||
|
customer: customer.stripe_id!,
|
||||||
|
});
|
||||||
|
|
||||||
|
const latestInvoice = stripeInvoices.data[0];
|
||||||
|
expect(latestInvoice.lines.data.length).toBe(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
17
server/tests/billing/utils/expectLatestInvoiceCorrect.ts
Normal file
17
server/tests/billing/utils/expectLatestInvoiceCorrect.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { expect } from "bun:test";
|
||||||
|
import type { ApiCustomer } from "@autumn/shared";
|
||||||
|
|
||||||
|
export const expectLatestInvoiceCorrect = ({
|
||||||
|
customer,
|
||||||
|
productId,
|
||||||
|
amount,
|
||||||
|
}: {
|
||||||
|
customer: ApiCustomer;
|
||||||
|
productId: string;
|
||||||
|
amount: number;
|
||||||
|
}) => {
|
||||||
|
const invoices = customer.invoices;
|
||||||
|
|
||||||
|
expect(invoices?.[0].total).toBe(amount);
|
||||||
|
expect(invoices?.[0].plan_ids).toContain(productId);
|
||||||
|
};
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, test } from "bun:test";
|
import { describe, expect, test } from "bun:test";
|
||||||
import type { SubscriptionUpdateV0Params } from "@autumn/shared";
|
import type { UpdateSubscriptionV0Params } from "@autumn/shared";
|
||||||
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
|
import { createMockCtx } from "@tests/utils/mockUtils/contextMocks";
|
||||||
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
import { createMockCustomerProduct } from "@tests/utils/mockUtils/cusProductMocks";
|
||||||
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
|
import { createMockFeature } from "@tests/utils/mockUtils/featureMocks";
|
||||||
@@ -41,7 +41,7 @@ describe(
|
|||||||
customerPrices: [createMockCustomerPrice({ price })],
|
customerPrices: [createMockCustomerPrice({ price })],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
// No options provided
|
// No options provided
|
||||||
@@ -75,7 +75,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 50 }],
|
options: [{ feature_id: "credits", quantity: 50 }],
|
||||||
@@ -117,7 +117,7 @@ describe(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 200 }],
|
options: [{ feature_id: "credits", quantity: 200 }],
|
||||||
@@ -195,7 +195,7 @@ describe(
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
|
options: [{ feature_id: "seats", quantity: 10 }], // Only updating seats
|
||||||
@@ -240,7 +240,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
|
options: [{ feature_id: "credits", quantity: 150 }], // Should round up to 200
|
||||||
@@ -276,7 +276,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
|
options: [{ feature_id: "credits", quantity: 200 }], // Exact multiple
|
||||||
@@ -311,7 +311,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
|
options: [{ feature_id: "credits", quantity: 1 }], // Should round to 1000
|
||||||
@@ -367,7 +367,7 @@ describe(
|
|||||||
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
// No options - should inherit from current
|
// No options - should inherit from current
|
||||||
@@ -424,7 +424,7 @@ describe(
|
|||||||
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
customerPrices: [createMockCustomerPrice({ price: oldPrice })],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
};
|
};
|
||||||
@@ -472,7 +472,7 @@ describe(
|
|||||||
// No customerPrices - can't interpret stored quantity
|
// No customerPrices - can't interpret stored quantity
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
};
|
};
|
||||||
@@ -511,7 +511,7 @@ describe(
|
|||||||
|
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 50 }],
|
options: [{ feature_id: "credits", quantity: 50 }],
|
||||||
@@ -537,7 +537,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [] });
|
const fullProduct = createMockFullProduct({ prices: [] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
};
|
};
|
||||||
@@ -563,7 +563,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] });
|
const cusProduct = createMockCustomerProduct({ options: [] });
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
};
|
};
|
||||||
@@ -595,7 +595,7 @@ describe(
|
|||||||
const fullProduct = createMockFullProduct({ prices: [price] });
|
const fullProduct = createMockFullProduct({ prices: [price] });
|
||||||
const cusProduct = createMockCustomerProduct({ options: [] }); // No current options
|
const cusProduct = createMockCustomerProduct({ options: [] }); // No current options
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
// No options in params either
|
// No options in params either
|
||||||
@@ -637,7 +637,7 @@ describe(
|
|||||||
customerPrices: [createMockCustomerPrice({ price })],
|
customerPrices: [createMockCustomerPrice({ price })],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [], // Explicitly empty
|
options: [], // Explicitly empty
|
||||||
@@ -680,7 +680,7 @@ describe(
|
|||||||
customerPrices: [createMockCustomerPrice({ price })],
|
customerPrices: [createMockCustomerPrice({ price })],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
options: [{ feature_id: "credits", quantity: 0 }],
|
options: [{ feature_id: "credits", quantity: 0 }],
|
||||||
@@ -726,7 +726,7 @@ describe(
|
|||||||
customerPrices: [createMockCustomerPrice({ price })],
|
customerPrices: [createMockCustomerPrice({ price })],
|
||||||
});
|
});
|
||||||
|
|
||||||
const params: SubscriptionUpdateV0Params = {
|
const params: UpdateSubscriptionV0Params = {
|
||||||
customer_id: "cus_test",
|
customer_id: "cus_test",
|
||||||
product_id: "prod_test",
|
product_id: "prod_test",
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import {
|
|||||||
AffectedResource,
|
AffectedResource,
|
||||||
defineVersionChange,
|
defineVersionChange,
|
||||||
} from "../../../versionUtils/versionChangeUtils/VersionChange";
|
} from "../../../versionUtils/versionChangeUtils/VersionChange";
|
||||||
import { SubscriptionUpdateV0ParamsSchema } from "../subscriptionUpdateV0Params";
|
|
||||||
import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params";
|
import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params";
|
||||||
|
import { UpdateSubscriptionV0ParamsSchema } from "../UpdateSubscriptionV0Params";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* V2_0_SubscriptionUpdateChange: Transforms subscription update params from V2.0 to V2.1 format
|
* V2_0_SubscriptionUpdateChange: Transforms subscription update params from V2.0 to V2.1 format
|
||||||
@@ -18,7 +18,7 @@ import { SubscriptionUpdateV1ParamsSchema } from "../subscriptionUpdateV1Params"
|
|||||||
* 2. Removed fields: `entity_id`, `customer_data`, `entity_data`, `options`, invoice settings
|
* 2. Removed fields: `entity_id`, `customer_data`, `entity_data`, `options`, invoice settings
|
||||||
* 3. Added field: `plan_override` for customizations
|
* 3. Added field: `plan_override` for customizations
|
||||||
*
|
*
|
||||||
* Input: SubscriptionUpdateV0Params (V2.0 format)
|
* Input: UpdateSubscriptionV0Params (V2.0 format)
|
||||||
* Output: SubscriptionUpdateV1Params (V2.1 format)
|
* Output: SubscriptionUpdateV1Params (V2.1 format)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -31,15 +31,15 @@ export const V2_0_SubscriptionUpdateChange = defineVersionChange({
|
|||||||
],
|
],
|
||||||
affectedResources: [AffectedResource.ApiSubscriptionUpdate],
|
affectedResources: [AffectedResource.ApiSubscriptionUpdate],
|
||||||
newSchema: SubscriptionUpdateV1ParamsSchema,
|
newSchema: SubscriptionUpdateV1ParamsSchema,
|
||||||
oldSchema: SubscriptionUpdateV0ParamsSchema,
|
oldSchema: UpdateSubscriptionV0ParamsSchema,
|
||||||
affectsRequest: true,
|
affectsRequest: true,
|
||||||
affectsResponse: false,
|
affectsResponse: false,
|
||||||
|
|
||||||
// Request: V0 (SubscriptionUpdateV0Params) → V1 (SubscriptionUpdateV1Params)
|
// Request: V0 (UpdateSubscriptionV0Params) → V1 (SubscriptionUpdateV1Params)
|
||||||
transformRequest: ({
|
transformRequest: ({
|
||||||
input,
|
input,
|
||||||
}: {
|
}: {
|
||||||
input: z.infer<typeof SubscriptionUpdateV0ParamsSchema>;
|
input: z.infer<typeof UpdateSubscriptionV0ParamsSchema>;
|
||||||
}): z.infer<typeof SubscriptionUpdateV1ParamsSchema> => {
|
}): z.infer<typeof SubscriptionUpdateV1ParamsSchema> => {
|
||||||
const planId = input.product_id;
|
const planId = input.product_id;
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { ProductItemSchema } from "../../../models/productV2Models/productItemMo
|
|||||||
import { CustomerDataSchema } from "../../common/customerData";
|
import { CustomerDataSchema } from "../../common/customerData";
|
||||||
import { EntityDataSchema } from "../../models";
|
import { EntityDataSchema } from "../../models";
|
||||||
|
|
||||||
export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
|
export const ExtUpdateSubscriptionV0ParamsSchema = z.object({
|
||||||
// Customer / Entity Info
|
// Customer / Entity Info
|
||||||
customer_id: z.string(),
|
customer_id: z.string(),
|
||||||
product_id: z.string().nullish(),
|
product_id: z.string().nullish(),
|
||||||
@@ -33,17 +33,17 @@ export const ExtSubscriptionUpdateV0ParamsSchema = z.object({
|
|||||||
prorate_billing: z.boolean().optional(),
|
prorate_billing: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const SubscriptionUpdateV0ParamsSchema =
|
export const UpdateSubscriptionV0ParamsSchema =
|
||||||
ExtSubscriptionUpdateV0ParamsSchema.extend({
|
ExtUpdateSubscriptionV0ParamsSchema.extend({
|
||||||
customer_product_id: z.string().optional(),
|
customer_product_id: z.string().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ExtSubscriptionUpdateV0Params = z.infer<
|
export type ExtUpdateSubscriptionV0Params = z.infer<
|
||||||
typeof ExtSubscriptionUpdateV0ParamsSchema
|
typeof ExtUpdateSubscriptionV0ParamsSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export type SubscriptionUpdateV0Params = z.infer<
|
export type UpdateSubscriptionV0Params = z.infer<
|
||||||
typeof SubscriptionUpdateV0ParamsSchema
|
typeof UpdateSubscriptionV0ParamsSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
// keep_existing_plan: true, //disable_plan_switch
|
// keep_existing_plan: true, //disable_plan_switch
|
||||||
@@ -76,9 +76,9 @@ export * from "./billing/checkout/checkoutParamsV1.js";
|
|||||||
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
||||||
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
export * from "./billing/checkout/prevVersions/checkoutParamsV0.js";
|
||||||
export * from "./billing/checkout/prevVersions/checkoutResponseV0.js";
|
export * from "./billing/checkout/prevVersions/checkoutResponseV0.js";
|
||||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
|
|
||||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV0Params.js";
|
|
||||||
export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js";
|
export * from "./billing/subscriptionUpdate/subscriptionUpdateV1Params.js";
|
||||||
|
export * from "./billing/subscriptionUpdate/updateSubscriptionV0Params.js";
|
||||||
|
|
||||||
export * from "./common/customerData.js";
|
export * from "./common/customerData.js";
|
||||||
export * from "./common/entityData.js";
|
export * from "./common/entityData.js";
|
||||||
export * from "./common/pagePaginationSchemas.js";
|
export * from "./common/pagePaginationSchemas.js";
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ export const LineItemSchema = z
|
|||||||
|
|
||||||
stripePriceId: z.string().optional(),
|
stripePriceId: z.string().optional(),
|
||||||
stripeProductId: z.string().optional(),
|
stripeProductId: z.string().optional(),
|
||||||
|
|
||||||
|
// Optional - for testing
|
||||||
|
chargeImmediately: z.boolean().default(true),
|
||||||
})
|
})
|
||||||
.transform((data) => {
|
.transform((data) => {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export enum MetadataType {
|
|||||||
InvoiceCheckout = "invoice_checkout",
|
InvoiceCheckout = "invoice_checkout",
|
||||||
CheckoutSessionCompleted = "checkout_session_completed",
|
CheckoutSessionCompleted = "checkout_session_completed",
|
||||||
DeferredAutumnBillingPlan = "deferred_autumn_billing_plan",
|
DeferredAutumnBillingPlan = "deferred_autumn_billing_plan",
|
||||||
|
|
||||||
|
InvoiceActionRequiredV2 = "invoice_action_required_v2",
|
||||||
|
InvoiceCheckoutV2 = "invoice_checkout_v2",
|
||||||
}
|
}
|
||||||
|
|
||||||
export const metadata = pgTable("metadata", {
|
export const metadata = pgTable("metadata", {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export const buildLineItem = ({
|
|||||||
stripePriceId,
|
stripePriceId,
|
||||||
stripeProductId,
|
stripeProductId,
|
||||||
shouldProrate = true,
|
shouldProrate = true,
|
||||||
|
chargeImmediately = true,
|
||||||
}: {
|
}: {
|
||||||
context: LineItemContext;
|
context: LineItemContext;
|
||||||
amount: number;
|
amount: number;
|
||||||
@@ -22,6 +23,7 @@ export const buildLineItem = ({
|
|||||||
stripePriceId?: string;
|
stripePriceId?: string;
|
||||||
stripeProductId?: string;
|
stripeProductId?: string;
|
||||||
shouldProrate?: boolean;
|
shouldProrate?: boolean;
|
||||||
|
chargeImmediately?: boolean;
|
||||||
}): LineItem => {
|
}): LineItem => {
|
||||||
// 1. Apply proration if needed
|
// 1. Apply proration if needed
|
||||||
if (shouldProrate && context.billingPeriod) {
|
if (shouldProrate && context.billingPeriod) {
|
||||||
@@ -44,5 +46,6 @@ export const buildLineItem = ({
|
|||||||
context,
|
context,
|
||||||
stripePriceId,
|
stripePriceId,
|
||||||
stripeProductId,
|
stripeProductId,
|
||||||
|
chargeImmediately,
|
||||||
} satisfies LineItemCreate);
|
} satisfies LineItemCreate);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -17,9 +17,13 @@ import { buildLineItem } from "./buildLineItem";
|
|||||||
export const usagePriceToLineItem = ({
|
export const usagePriceToLineItem = ({
|
||||||
cusEnt,
|
cusEnt,
|
||||||
context,
|
context,
|
||||||
|
shouldProrateOverride,
|
||||||
|
chargeImmediatelyOverride,
|
||||||
}: {
|
}: {
|
||||||
cusEnt: FullCusEntWithFullCusProduct;
|
cusEnt: FullCusEntWithFullCusProduct;
|
||||||
context: LineItemContext;
|
context: LineItemContext;
|
||||||
|
shouldProrateOverride?: boolean;
|
||||||
|
chargeImmediatelyOverride?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const cusPrice = cusEntToCusPrice({ cusEnt });
|
const cusPrice = cusEntToCusPrice({ cusEnt });
|
||||||
const { feature } = context;
|
const { feature } = context;
|
||||||
@@ -75,8 +79,8 @@ export const usagePriceToLineItem = ({
|
|||||||
// 5. Get stripe price / product IDs
|
// 5. Get stripe price / product IDs
|
||||||
const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt });
|
const { stripePriceId, stripeProductId } = cusEntToStripeIds({ cusEnt });
|
||||||
|
|
||||||
// 6. Should prorate: don't if consumable price
|
// 6. Should prorate: don't if consumable price (unless override provided)
|
||||||
const shouldProrate = !isConsumablePrice(price);
|
const shouldProrate = shouldProrateOverride ?? !isConsumablePrice(price);
|
||||||
|
|
||||||
return buildLineItem({
|
return buildLineItem({
|
||||||
context,
|
context,
|
||||||
@@ -87,5 +91,6 @@ export const usagePriceToLineItem = ({
|
|||||||
stripeProductId,
|
stripeProductId,
|
||||||
|
|
||||||
shouldProrate,
|
shouldProrate,
|
||||||
|
chargeImmediately: chargeImmediatelyOverride,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,11 @@ export const tiersToLineAmount = ({
|
|||||||
overage: number;
|
overage: number;
|
||||||
billingUnits?: number;
|
billingUnits?: number;
|
||||||
}): number => {
|
}): number => {
|
||||||
|
const isNegative = overage < 0;
|
||||||
|
const absoluteOverage = Math.abs(overage);
|
||||||
|
|
||||||
const roundedOverage = roundUsageToNearestBillingUnit({
|
const roundedOverage = roundUsageToNearestBillingUnit({
|
||||||
usage: overage,
|
usage: absoluteOverage,
|
||||||
billingUnits,
|
billingUnits,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -47,5 +50,6 @@ export const tiersToLineAmount = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return amount.toDecimalPlaces(10).toNumber();
|
const finalAmount = amount.toDecimalPlaces(10).toNumber();
|
||||||
|
return isNegative ? -finalAmount : finalAmount;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -54,18 +54,33 @@ export const shouldProrateDowngradeNow = ({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if a quantity upgrade or downgrade should be prorated.
|
* Determines if a quantity upgrade or downgrade should be prorated.
|
||||||
* @param onIncrease - The proration behavior configuration for the quantity increase.
|
* @param prorationConfig - The proration behavior configuration.
|
||||||
* @returns True if a quantity upgrade or downgrade should be prorated, false otherwise.
|
* @returns True if proration should be applied, false otherwise.
|
||||||
*/
|
*/
|
||||||
export const shouldProrate = (onIncrease?: OnIncrease | OnDecrease) => {
|
export const shouldProrate = (prorationConfig?: OnIncrease | OnDecrease) => {
|
||||||
if (!onIncrease) {
|
if (!prorationConfig) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
onIncrease === OnIncrease.ProrateNextCycle ||
|
prorationConfig === OnIncrease.ProrateNextCycle ||
|
||||||
onIncrease === OnIncrease.ProrateImmediately ||
|
prorationConfig === OnIncrease.ProrateImmediately ||
|
||||||
onIncrease === OnDecrease.ProrateImmediately ||
|
prorationConfig === OnDecrease.ProrateImmediately ||
|
||||||
onIncrease === OnDecrease.ProrateNextCycle
|
prorationConfig === OnDecrease.ProrateNextCycle
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determines if line items should be skipped entirely (no charge or refund).
|
||||||
|
* @param prorationConfig - The proration behavior configuration.
|
||||||
|
* @returns True if line items should be skipped, false otherwise.
|
||||||
|
*/
|
||||||
|
export const shouldSkipLineItems = (
|
||||||
|
prorationConfig: OnIncrease | OnDecrease,
|
||||||
|
) => {
|
||||||
|
return (
|
||||||
|
prorationConfig === OnDecrease.NoProrations ||
|
||||||
|
prorationConfig === OnDecrease.None ||
|
||||||
|
prorationConfig === OnIncrease.BillNextCycle
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import { CusProductStatus, type FullCustomer } from "../../index.js";
|
import {
|
||||||
|
type FullCustomer,
|
||||||
|
filterCustomerProductsByActiveStatuses,
|
||||||
|
} from "../../index.js";
|
||||||
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
|
import type { Entity } from "../../models/cusModels/entityModels/entityModels.js";
|
||||||
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
import type { FullCustomerEntitlement } from "../../models/cusProductModels/cusEntModels/cusEntModels.js";
|
||||||
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
import type { FullCusProduct } from "../../models/cusProductModels/cusProductModels.js";
|
||||||
@@ -96,16 +99,6 @@ export const filterOutEntitiesFromCusProducts = ({
|
|||||||
return finalCusProducts;
|
return finalCusProducts;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getActiveCusProducts = ({
|
|
||||||
customer,
|
|
||||||
}: {
|
|
||||||
customer: FullCustomer;
|
|
||||||
}): FullCusProduct[] => {
|
|
||||||
return customer.customer_products.filter(
|
|
||||||
(p: FullCusProduct) => p.status === CusProductStatus.Active,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isProductAlreadyEnabled = ({
|
export const isProductAlreadyEnabled = ({
|
||||||
productId,
|
productId,
|
||||||
customer,
|
customer,
|
||||||
@@ -115,7 +108,9 @@ export const isProductAlreadyEnabled = ({
|
|||||||
customer: FullCustomer;
|
customer: FullCustomer;
|
||||||
entityId?: string;
|
entityId?: string;
|
||||||
}) => {
|
}) => {
|
||||||
return getActiveCusProducts({ customer }).some((cp: FullCusProduct) => {
|
return filterCustomerProductsByActiveStatuses({
|
||||||
|
customerProducts: customer.customer_products,
|
||||||
|
}).some((cp: FullCusProduct) => {
|
||||||
// Check if product matches and is not an add-on
|
// Check if product matches and is not an add-on
|
||||||
if (cp.product_id !== productId || cp.product.is_add_on) {
|
if (cp.product_id !== productId || cp.product.is_add_on) {
|
||||||
return false;
|
return false;
|
||||||
@@ -142,17 +137,3 @@ export const isProductAlreadyEnabled = ({
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterCusProductsBySubId = ({
|
|
||||||
cusProducts,
|
|
||||||
subId,
|
|
||||||
}: {
|
|
||||||
cusProducts: FullCusProduct[];
|
|
||||||
subId?: string;
|
|
||||||
}): FullCusProduct[] => {
|
|
||||||
if (!subId) return [];
|
|
||||||
|
|
||||||
return cusProducts.filter((cp: FullCusProduct) =>
|
|
||||||
cp.subscription_ids?.includes(subId),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
|
||||||
|
import { ACTIVE_STATUSES } from "@utils/cusProductUtils/cusProductConstants";
|
||||||
|
|
||||||
|
export const filterCustomerProductsByActiveStatuses = ({
|
||||||
|
customerProducts,
|
||||||
|
}: {
|
||||||
|
customerProducts: FullCusProduct[];
|
||||||
|
}) => {
|
||||||
|
return customerProducts.filter((customerProduct) =>
|
||||||
|
ACTIVE_STATUSES.includes(customerProduct.status),
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import type { FullCusProduct } from "@models/cusProductModels/cusProductModels";
|
||||||
|
|
||||||
|
export const filterCustomerProductsByStripeSubscriptionId = ({
|
||||||
|
customerProducts,
|
||||||
|
stripeSubscriptionId,
|
||||||
|
}: {
|
||||||
|
customerProducts: FullCusProduct[];
|
||||||
|
stripeSubscriptionId?: string;
|
||||||
|
}) => {
|
||||||
|
return customerProducts.filter((customerProduct) => {
|
||||||
|
if (!stripeSubscriptionId) {
|
||||||
|
return (
|
||||||
|
customerProduct.subscription_ids?.length === 0 ||
|
||||||
|
!customerProduct.subscription_ids
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return customerProduct.subscription_ids?.includes(stripeSubscriptionId);
|
||||||
|
});
|
||||||
|
};
|
||||||
@@ -39,7 +39,9 @@ export * from "./cusProductUtils/cusProductConstants.js";
|
|||||||
export * from "./cusProductUtils/cusProductUtils.js";
|
export * from "./cusProductUtils/cusProductUtils.js";
|
||||||
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
|
export * from "./cusProductUtils/featureOptionUtils/findFeatureOptions.js";
|
||||||
export * from "./cusProductUtils/filterCusProductUtils.js";
|
export * from "./cusProductUtils/filterCusProductUtils.js";
|
||||||
export * from "./cusProductUtils/filterCusProductUtils.js";
|
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByActiveStatuses.js";
|
||||||
|
export * from "./cusProductUtils/filterCustomerProducts/filterCustomerProductsByStripeSubscriptionId.js";
|
||||||
|
|
||||||
export * from "./cusProductUtils/getCusProductFromCustomer.js";
|
export * from "./cusProductUtils/getCusProductFromCustomer.js";
|
||||||
export * from "./cusProductUtils/productIdToCusProduct.js";
|
export * from "./cusProductUtils/productIdToCusProduct.js";
|
||||||
// Cus utils
|
// Cus utils
|
||||||
@@ -67,10 +69,7 @@ export * from "./productUtils/entUtils/formatEntUtils.js";
|
|||||||
export * from "./productUtils/freeTrialUtils/initFreeTrial.js";
|
export * from "./productUtils/freeTrialUtils/initFreeTrial.js";
|
||||||
export * from "./productUtils/freeTrialUtils.js";
|
export * from "./productUtils/freeTrialUtils.js";
|
||||||
export * from "./productUtils/isProductUpgrade.js";
|
export * from "./productUtils/isProductUpgrade.js";
|
||||||
export * from "./productUtils/priceUtils/classifyPriceUtils.js";
|
export * from "./productUtils/priceUtils/index.js";
|
||||||
export * from "./productUtils/priceUtils/convertAmountUtils.js";
|
|
||||||
export * from "./productUtils/priceUtils/convertPriceUtils.js";
|
|
||||||
export * from "./productUtils/priceUtils/formatPriceUtils.js";
|
|
||||||
export * from "./productUtils/priceUtils.js";
|
export * from "./productUtils/priceUtils.js";
|
||||||
export * from "./productV2Utils/mapToProductV2.js";
|
export * from "./productV2Utils/mapToProductV2.js";
|
||||||
export * from "./productV2Utils/productItemUtils/classifyItemUtils.js";
|
export * from "./productV2Utils/productItemUtils/classifyItemUtils.js";
|
||||||
|
|||||||
@@ -6,7 +6,11 @@ import {
|
|||||||
OnDecrease,
|
OnDecrease,
|
||||||
OnIncrease,
|
OnIncrease,
|
||||||
} from "@models/productV2Models/productItemModels/productItemEnums";
|
} from "@models/productV2Models/productItemModels/productItemEnums";
|
||||||
import { shouldBillNow, shouldProrate } from "@utils/billingUtils";
|
import {
|
||||||
|
shouldBillNow,
|
||||||
|
shouldProrate,
|
||||||
|
shouldSkipLineItems,
|
||||||
|
} from "@utils/billingUtils";
|
||||||
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
|
import { priceToEnt } from "@utils/productUtils/convertProductUtils";
|
||||||
|
|
||||||
export const priceToFeature = ({
|
export const priceToFeature = ({
|
||||||
@@ -43,20 +47,17 @@ export const priceToProrationConfig = ({
|
|||||||
}): {
|
}): {
|
||||||
prorationBehaviorConfig: OnIncrease | OnDecrease;
|
prorationBehaviorConfig: OnIncrease | OnDecrease;
|
||||||
shouldApplyProration: boolean;
|
shouldApplyProration: boolean;
|
||||||
shouldFinalizeInvoiceImmediately: boolean;
|
chargeImmediately: boolean;
|
||||||
|
skipLineItems: boolean;
|
||||||
} => {
|
} => {
|
||||||
const prorationBehaviorConfig = isUpgrade
|
const prorationBehaviorConfig = isUpgrade
|
||||||
? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately)
|
? (price.proration_config?.on_increase ?? OnIncrease.ProrateImmediately)
|
||||||
: (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately);
|
: (price.proration_config?.on_decrease ?? OnDecrease.ProrateImmediately);
|
||||||
|
|
||||||
const shouldApplyProration = shouldProrate(prorationBehaviorConfig);
|
|
||||||
const shouldFinalizeInvoiceImmediately = shouldBillNow(
|
|
||||||
prorationBehaviorConfig,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
prorationBehaviorConfig,
|
prorationBehaviorConfig,
|
||||||
shouldApplyProration,
|
shouldApplyProration: shouldProrate(prorationBehaviorConfig),
|
||||||
shouldFinalizeInvoiceImmediately,
|
chargeImmediately: shouldBillNow(prorationBehaviorConfig),
|
||||||
|
skipLineItems: shouldSkipLineItems(prorationBehaviorConfig),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { Price } from "@models/productModels/priceModels/priceModels";
|
||||||
|
|
||||||
|
export const findPriceByFeatureId = ({
|
||||||
|
prices,
|
||||||
|
featureId,
|
||||||
|
}: {
|
||||||
|
prices: Price[];
|
||||||
|
featureId: string;
|
||||||
|
}) => {
|
||||||
|
return prices.find((p) => p.config.feature_id === featureId);
|
||||||
|
};
|
||||||
5
shared/utils/productUtils/priceUtils/index.ts
Normal file
5
shared/utils/productUtils/priceUtils/index.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./classifyPriceUtils.js";
|
||||||
|
export * from "./convertAmountUtils.js";
|
||||||
|
export * from "./convertPriceUtils.js";
|
||||||
|
export * from "./findPrice/findPriceByFeatureId.js";
|
||||||
|
export * from "./formatPriceUtils.js";
|
||||||
@@ -7,23 +7,32 @@ import {
|
|||||||
getProductItemDisplay,
|
getProductItemDisplay,
|
||||||
type ProductItem,
|
type ProductItem,
|
||||||
type ProductV2,
|
type ProductV2,
|
||||||
type SubscriptionUpdateV0Params,
|
|
||||||
stripeToAtmnAmount,
|
stripeToAtmnAmount,
|
||||||
|
type UpdateSubscriptionV0Params,
|
||||||
} from "@autumn/shared";
|
} from "@autumn/shared";
|
||||||
import { Check, Copy, PencilSimple } from "@phosphor-icons/react";
|
import { Check, Copy, PencilSimple } from "@phosphor-icons/react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router";
|
import { useNavigate } from "react-router";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { DateInputUnix } from "@/components/general/DateInputUnix";
|
import { DateInputUnix } from "@/components/general/DateInputUnix";
|
||||||
|
import {
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/v2/buttons/Button";
|
import { Button } from "@/components/v2/buttons/Button";
|
||||||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||||||
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
|
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
|
||||||
|
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||||||
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
|
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
|
||||||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||||||
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
|
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
|
||||||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||||||
|
import { useEnv } from "@/utils/envUtils";
|
||||||
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
||||||
import { pushPage } from "@/utils/genUtils";
|
import { pushPage } from "@/utils/genUtils";
|
||||||
|
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||||||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,6 +50,7 @@ interface PrepaidEditorProps {
|
|||||||
prepaidItems: Array<{
|
prepaidItems: Array<{
|
||||||
feature_id?: string | null;
|
feature_id?: string | null;
|
||||||
feature?: { internal_id: string } | undefined;
|
feature?: { internal_id: string } | undefined;
|
||||||
|
billing_units?: number | null;
|
||||||
}>;
|
}>;
|
||||||
prepaidOptions: Record<string, number>;
|
prepaidOptions: Record<string, number>;
|
||||||
onPrepaidChange: (featureId: string, quantity: number) => void;
|
onPrepaidChange: (featureId: string, quantity: number) => void;
|
||||||
@@ -61,15 +71,18 @@ function PrepaidEditor({
|
|||||||
<div className="px-4 py-3 space-y-2">
|
<div className="px-4 py-3 space-y-2">
|
||||||
{prepaidItems.map((item) => {
|
{prepaidItems.map((item) => {
|
||||||
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
||||||
|
const billingUnits = item.billing_units ?? 1;
|
||||||
const inputId = `prepaid-${featureId}`;
|
const inputId = `prepaid-${featureId}`;
|
||||||
return (
|
return (
|
||||||
<div key={featureId} className="flex items-center gap-3">
|
<div key={featureId} className="flex items-center gap-3">
|
||||||
<label
|
<div className="flex-1">
|
||||||
htmlFor={inputId}
|
<label htmlFor={inputId} className="text-sm text-t-secondary">
|
||||||
className="text-sm text-t-secondary flex-1"
|
|
||||||
>
|
|
||||||
{featureId}
|
{featureId}
|
||||||
</label>
|
</label>
|
||||||
|
<span className="text-xs text-t-secondary ml-2">
|
||||||
|
(billing_units: {billingUnits})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
id={inputId}
|
id={inputId}
|
||||||
type="number"
|
type="number"
|
||||||
@@ -158,6 +171,14 @@ interface BillingPlanData {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
invoiceItemsAction?: {
|
||||||
|
createInvoiceItems?: Array<{
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
customer?: string;
|
||||||
|
subscription?: string;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,10 +647,16 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
|||||||
<h4 className="text-xs font-semibold text-purple-400 mb-1">
|
<h4 className="text-xs font-semibold text-purple-400 mb-1">
|
||||||
🧾 Stripe Invoice Action
|
🧾 Stripe Invoice Action
|
||||||
</h4>
|
</h4>
|
||||||
|
|
||||||
|
{/* Immediate Line Items (addLineParams) */}
|
||||||
{billingPlan.stripe.invoiceAction.addLineParams?.lines &&
|
{billingPlan.stripe.invoiceAction.addLineParams?.lines &&
|
||||||
billingPlan.stripe.invoiceAction.addLineParams.lines.length >
|
billingPlan.stripe.invoiceAction.addLineParams.lines.length >
|
||||||
0 ? (
|
0 ? (
|
||||||
<div className="space-y-1">
|
<div className="mb-2">
|
||||||
|
<div className="text-xs text-t-secondary font-medium mb-1">
|
||||||
|
Immediate charges:
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 pl-2 border-l border-purple-500/30">
|
||||||
{billingPlan.stripe.invoiceAction.addLineParams.lines.map(
|
{billingPlan.stripe.invoiceAction.addLineParams.lines.map(
|
||||||
(
|
(
|
||||||
line: {
|
line: {
|
||||||
@@ -664,9 +691,13 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
|||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-t-secondary">No line items</div>
|
<div className="text-xs text-t-secondary mb-2">
|
||||||
|
No immediate line items
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<details className="mt-1">
|
<details className="mt-1">
|
||||||
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
|
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
|
||||||
View raw params
|
View raw params
|
||||||
@@ -678,10 +709,71 @@ function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{/* Stripe Invoice Items Action - Deferred charges added to next cycle */}
|
||||||
|
{billingPlan.stripe?.invoiceItemsAction?.createInvoiceItems &&
|
||||||
|
billingPlan.stripe.invoiceItemsAction.createInvoiceItems.length >
|
||||||
|
0 ? (
|
||||||
|
<div className="px-4 py-3 border-l-2 border-l-amber-500">
|
||||||
|
<h4 className="text-xs font-semibold text-amber-400 mb-1">
|
||||||
|
⏱️ Stripe Invoice Items Action (Added to next cycle)
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1 pl-2 border-l border-amber-500/30">
|
||||||
|
{billingPlan.stripe.invoiceItemsAction.createInvoiceItems.map(
|
||||||
|
(
|
||||||
|
item: {
|
||||||
|
description?: string;
|
||||||
|
amount?: number;
|
||||||
|
customer?: string;
|
||||||
|
subscription?: string;
|
||||||
|
},
|
||||||
|
index: number,
|
||||||
|
) => {
|
||||||
|
const amount = item.amount
|
||||||
|
? stripeToAtmnAmount({
|
||||||
|
amount: item.amount,
|
||||||
|
currency: "usd",
|
||||||
|
})
|
||||||
|
: 0;
|
||||||
|
return (
|
||||||
|
<div key={index} className="flex justify-between text-xs">
|
||||||
|
<span className="text-t-primary">
|
||||||
|
{item.description || "Invoice item"}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
amount >= 0 ? "text-amber-400" : "text-red-400"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
${amount.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-t-secondary mt-1 italic">
|
||||||
|
These items will appear on the customer's next invoice
|
||||||
|
</div>
|
||||||
|
<details className="mt-1">
|
||||||
|
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
|
||||||
|
View raw params
|
||||||
|
</summary>
|
||||||
|
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
|
||||||
|
{JSON.stringify(
|
||||||
|
billingPlan.stripe.invoiceItemsAction,
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{/* Empty Stripe section indicator */}
|
{/* Empty Stripe section indicator */}
|
||||||
{billingPlan.stripe &&
|
{billingPlan.stripe &&
|
||||||
!billingPlan.stripe.subscriptionAction &&
|
!billingPlan.stripe.subscriptionAction &&
|
||||||
!billingPlan.stripe.invoiceAction ? (
|
!billingPlan.stripe.invoiceAction &&
|
||||||
|
!billingPlan.stripe.invoiceItemsAction ? (
|
||||||
<div className="px-4 py-2 text-xs text-t-secondary">
|
<div className="px-4 py-2 text-xs text-t-secondary">
|
||||||
No Stripe actions required
|
No Stripe actions required
|
||||||
</div>
|
</div>
|
||||||
@@ -740,7 +832,7 @@ function useSubscriptionUpdatePreview({
|
|||||||
body,
|
body,
|
||||||
enabled,
|
enabled,
|
||||||
}: {
|
}: {
|
||||||
body: SubscriptionUpdateV0Params | null;
|
body: UpdateSubscriptionV0Params | null;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
}) {
|
}) {
|
||||||
const axiosInstance = useAxiosInstance();
|
const axiosInstance = useAxiosInstance();
|
||||||
@@ -780,17 +872,64 @@ function useSubscriptionUpdatePreview({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSubscriptionUpdate() {
|
interface SubscriptionUpdateParams {
|
||||||
|
body: UpdateSubscriptionV0Params;
|
||||||
|
useInvoice?: boolean;
|
||||||
|
enableProductImmediately?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSubscriptionUpdate({
|
||||||
|
customerId,
|
||||||
|
onInvoiceCreated,
|
||||||
|
}: {
|
||||||
|
customerId?: string;
|
||||||
|
onInvoiceCreated?: (invoiceLink: string) => void;
|
||||||
|
}) {
|
||||||
const axiosInstance = useAxiosInstance();
|
const axiosInstance = useAxiosInstance();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { closeSheet } = useSheetStore();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async (body: SubscriptionUpdateV0Params) => {
|
mutationFn: async ({
|
||||||
|
body,
|
||||||
|
useInvoice,
|
||||||
|
enableProductImmediately,
|
||||||
|
}: SubscriptionUpdateParams) => {
|
||||||
|
const requestBody = {
|
||||||
|
...body,
|
||||||
|
invoice: useInvoice,
|
||||||
|
enable_product_immediately: useInvoice
|
||||||
|
? enableProductImmediately
|
||||||
|
: undefined,
|
||||||
|
finalize_invoice: useInvoice ? false : undefined,
|
||||||
|
force_checkout:
|
||||||
|
useInvoice && enableProductImmediately === false ? true : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
const response = await axiosInstance.post(
|
const response = await axiosInstance.post(
|
||||||
"/v1/subscriptions/update",
|
"/v1/subscriptions/update",
|
||||||
body,
|
requestBody,
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
onSuccess: (data) => {
|
||||||
|
if (data?.invoice) {
|
||||||
|
onInvoiceCreated?.(data.invoice);
|
||||||
|
toast.success("Invoice created successfully");
|
||||||
|
} else if (data?.checkout_url) {
|
||||||
|
toast.success("Redirecting to checkout...");
|
||||||
|
window.open(data.checkout_url, "_blank");
|
||||||
|
} else {
|
||||||
|
toast.success("Subscription updated successfully");
|
||||||
|
}
|
||||||
|
closeSheet();
|
||||||
|
if (customerId) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["customer", customerId] });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
console.error("Update failed:", error);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -808,6 +947,10 @@ function SheetContent({
|
|||||||
const customerId = customer?.id ?? customer?.internal_id;
|
const customerId = customer?.id ?? customer?.internal_id;
|
||||||
const entityId = cusProduct?.entity_id ?? undefined;
|
const entityId = cusProduct?.entity_id ?? undefined;
|
||||||
|
|
||||||
|
// Stripe + invoice handling
|
||||||
|
const { stripeAccount } = useOrgStripeQuery();
|
||||||
|
const env = useEnv();
|
||||||
|
|
||||||
const product = customizedProduct?.id ? customizedProduct : productV2;
|
const product = customizedProduct?.id ? customizedProduct : productV2;
|
||||||
const { prepaidItems } = usePrepaidItems({ product });
|
const { prepaidItems } = usePrepaidItems({ product });
|
||||||
|
|
||||||
@@ -874,23 +1017,30 @@ function SheetContent({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Build the request body
|
// Build the request body
|
||||||
const requestBody = useMemo<SubscriptionUpdateV0Params | null>(() => {
|
const requestBody = useMemo<UpdateSubscriptionV0Params | null>(() => {
|
||||||
if (!customerId) return null;
|
if (!customerId) return null;
|
||||||
|
|
||||||
const body: SubscriptionUpdateV0Params = {
|
const body: UpdateSubscriptionV0Params = {
|
||||||
customer_id: customerId,
|
customer_id: customerId,
|
||||||
product_id: product?.id,
|
product_id: product?.id,
|
||||||
entity_id: entityId,
|
entity_id: entityId,
|
||||||
customer_product_id: cusProduct.id ?? cusProduct.internal_product_id,
|
customer_product_id: cusProduct.id ?? cusProduct.internal_product_id,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Add options if there are prepaid items with quantities set
|
// Add options only if they have changed from initial values
|
||||||
if (prepaidItems.length > 0) {
|
if (prepaidItems.length > 0) {
|
||||||
const options = prepaidItems
|
const options = prepaidItems
|
||||||
.map((item) => {
|
.map((item) => {
|
||||||
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
||||||
const quantity = prepaidOptions[featureId];
|
const quantity = prepaidOptions[featureId];
|
||||||
if (quantity !== undefined && quantity !== null && featureId) {
|
const initialQuantity = initialPrepaidOptions[featureId];
|
||||||
|
// Only include if changed from initial value
|
||||||
|
if (
|
||||||
|
quantity !== undefined &&
|
||||||
|
quantity !== null &&
|
||||||
|
featureId &&
|
||||||
|
quantity !== initialQuantity
|
||||||
|
) {
|
||||||
return { feature_id: featureId, quantity };
|
return { feature_id: featureId, quantity };
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -935,6 +1085,7 @@ function SheetContent({
|
|||||||
cusProduct.internal_product_id,
|
cusProduct.internal_product_id,
|
||||||
prepaidItems,
|
prepaidItems,
|
||||||
prepaidOptions,
|
prepaidOptions,
|
||||||
|
initialPrepaidOptions,
|
||||||
customizedProduct?.items,
|
customizedProduct?.items,
|
||||||
customizedProduct?.free_trial,
|
customizedProduct?.free_trial,
|
||||||
planCustomStartDate,
|
planCustomStartDate,
|
||||||
@@ -948,12 +1099,35 @@ function SheetContent({
|
|||||||
enabled: !!requestBody,
|
enabled: !!requestBody,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update mutation
|
// Update mutation with invoice handling
|
||||||
const updateMutation = useSubscriptionUpdate();
|
const updateMutation = useSubscriptionUpdate({
|
||||||
|
customerId,
|
||||||
|
onInvoiceCreated: (stripeInvoice) => {
|
||||||
|
const invoiceLink = getStripeInvoiceLink({
|
||||||
|
stripeInvoice,
|
||||||
|
env,
|
||||||
|
accountId: stripeAccount?.id,
|
||||||
|
});
|
||||||
|
window.open(invoiceLink, "_blank");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const handleConfirm = () => {
|
const handleConfirm = () => {
|
||||||
if (!requestBody) return;
|
if (!requestBody) return;
|
||||||
updateMutation.mutate(requestBody);
|
updateMutation.mutate({ body: requestBody, useInvoice: false });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInvoiceUpdate = ({
|
||||||
|
enableProductImmediately,
|
||||||
|
}: {
|
||||||
|
enableProductImmediately: boolean;
|
||||||
|
}) => {
|
||||||
|
if (!requestBody) return;
|
||||||
|
updateMutation.mutate({
|
||||||
|
body: requestBody,
|
||||||
|
useInvoice: true,
|
||||||
|
enableProductImmediately,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1181,16 +1355,64 @@ function SheetContent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer Actions */}
|
{/* Footer Actions */}
|
||||||
<div className="p-4 border-t flex gap-3">
|
<div className="p-4 border-t flex flex-col gap-2">
|
||||||
|
{/* Send an Invoice Button with Dropdown */}
|
||||||
|
<Popover>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
|
disabled={!requestBody || updateMutation.isPending}
|
||||||
|
>
|
||||||
|
Send an Invoice
|
||||||
|
</Button>
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent className="w-80 p-0" align="start">
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleInvoiceUpdate({ enableProductImmediately: true })
|
||||||
|
}
|
||||||
|
className="px-4 py-3 text-left text-sm hover:bg-accent"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Enable plan immediately</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Enable the plan immediately and redirect to Stripe to finalize
|
||||||
|
the invoice
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
handleInvoiceUpdate({ enableProductImmediately: false })
|
||||||
|
}
|
||||||
|
className="px-4 py-3 text-left text-sm hover:bg-accent border-t"
|
||||||
|
>
|
||||||
|
<div className="font-medium">Enable plan after payment</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
Generate an invoice link for the customer. The plan will be
|
||||||
|
enabled after they pay the invoice
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
|
||||||
|
{/* Confirm Update Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
className="w-full"
|
||||||
onClick={handleConfirm}
|
onClick={handleConfirm}
|
||||||
disabled={!requestBody || updateMutation.isPending}
|
disabled={!requestBody || updateMutation.isPending}
|
||||||
>
|
>
|
||||||
{updateMutation.isPending ? "Updating..." : "Confirm Update"}
|
{updateMutation.isPending ? "Updating..." : "Confirm Update"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
{/* Refresh Preview Button */}
|
||||||
<Button
|
<Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
className="w-full"
|
||||||
onClick={() => previewQuery.refetch()}
|
onClick={() => previewQuery.refetch()}
|
||||||
disabled={!requestBody || previewQuery.isLoading}
|
disabled={!requestBody || previewQuery.isLoading}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@ export default defineConfig({
|
|||||||
"drizzle-orm",
|
"drizzle-orm",
|
||||||
"@date-fns/utc",
|
"@date-fns/utc",
|
||||||
"date-fns",
|
"date-fns",
|
||||||
|
"@owpz/ksuid",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user