1504 lines
44 KiB
TypeScript
1504 lines
44 KiB
TypeScript
import {
|
||
type Entity,
|
||
type Feature,
|
||
type FrontendProduct,
|
||
type FullCusProduct,
|
||
type FullCustomer,
|
||
getProductItemDisplay,
|
||
type ProductItem,
|
||
type ProductV2,
|
||
stripeToAtmnAmount,
|
||
type UpdateSubscriptionV0Params,
|
||
} from "@autumn/shared";
|
||
import { Check, Copy, PencilSimple } from "@phosphor-icons/react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
import { useNavigate } from "react-router";
|
||
import { toast } from "sonner";
|
||
import { DateInputUnix } from "@/components/general/DateInputUnix";
|
||
import {
|
||
Popover,
|
||
PopoverContent,
|
||
PopoverTrigger,
|
||
} from "@/components/ui/popover";
|
||
import { Button } from "@/components/v2/buttons/Button";
|
||
import { IconButton } from "@/components/v2/buttons/IconButton";
|
||
import { SheetHeader } from "@/components/v2/sheets/InlineSheet";
|
||
import { useOrgStripeQuery } from "@/hooks/queries/useOrgStripeQuery";
|
||
import { usePrepaidItems } from "@/hooks/stores/useProductStore";
|
||
import { useSheetStore } from "@/hooks/stores/useSheetStore";
|
||
import { useSubscriptionById } from "@/hooks/stores/useSubscriptionStore";
|
||
import { useAxiosInstance } from "@/services/useAxiosInstance";
|
||
import { useEnv } from "@/utils/envUtils";
|
||
import { formatUnixToDateTime } from "@/utils/formatUtils/formatDateUtils";
|
||
import { pushPage } from "@/utils/genUtils";
|
||
import { getStripeInvoiceLink } from "@/utils/linkUtils";
|
||
import { useCusQuery } from "@/views/customers/customer/hooks/useCusQuery";
|
||
|
||
/**
|
||
* TEST SHEET: SubscriptionUpdateTestSheet
|
||
*
|
||
* This is an isolated test sheet for testing the subscription update flow.
|
||
* It calls:
|
||
* - POST /v1/subscriptions/preview_update - to get a billing plan preview
|
||
* - POST /v1/subscriptions/update - to execute the update
|
||
*
|
||
* Usage: Open this sheet with an itemId (cusProduct id) and optional customizedProduct in data
|
||
*/
|
||
|
||
interface PrepaidEditorProps {
|
||
prepaidItems: Array<{
|
||
feature_id?: string | null;
|
||
feature?: { internal_id: string } | undefined;
|
||
billing_units?: number | null;
|
||
}>;
|
||
prepaidOptions: Record<string, number>;
|
||
onPrepaidChange: (featureId: string, quantity: number) => void;
|
||
}
|
||
|
||
function PrepaidEditor({
|
||
prepaidItems,
|
||
prepaidOptions,
|
||
onPrepaidChange,
|
||
}: PrepaidEditorProps) {
|
||
if (prepaidItems.length === 0) return null;
|
||
|
||
return (
|
||
<div className="border-b border-border">
|
||
<div className="px-4 py-2 border-b border-border">
|
||
<h3 className="text-sm font-medium">Prepaid Quantities</h3>
|
||
</div>
|
||
<div className="px-4 py-3 space-y-2">
|
||
{prepaidItems.map((item) => {
|
||
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
||
const billingUnits = item.billing_units ?? 1;
|
||
const inputId = `prepaid-${featureId}`;
|
||
return (
|
||
<div key={featureId} className="flex items-center gap-3">
|
||
<div className="flex-1">
|
||
<label htmlFor={inputId} className="text-sm text-t-secondary">
|
||
{featureId}
|
||
</label>
|
||
<span className="text-xs text-t-secondary ml-2">
|
||
(billing_units: {billingUnits})
|
||
</span>
|
||
</div>
|
||
<input
|
||
id={inputId}
|
||
type="number"
|
||
min={0}
|
||
value={prepaidOptions[featureId] ?? 0}
|
||
onChange={(e) =>
|
||
onPrepaidChange(featureId, parseInt(e.target.value, 10) || 0)
|
||
}
|
||
className="w-20 px-2 py-1 border border-border rounded text-sm bg-transparent"
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface BillingPlanData {
|
||
autumn?: {
|
||
freeTrialPlan?: {
|
||
freeTrial?: {
|
||
length?: number;
|
||
duration?: string;
|
||
} | null;
|
||
trialEndsAt?: number;
|
||
};
|
||
insertCustomerProducts?: Array<{
|
||
product?: { name?: string };
|
||
status?: string;
|
||
starts_at?: number;
|
||
trial_ends_at?: number;
|
||
customer_entitlements?: Array<{
|
||
feature_id?: string;
|
||
balance?: number;
|
||
next_reset_at?: number;
|
||
entitlement?: { feature?: { name?: string } };
|
||
}>;
|
||
}>;
|
||
updateCustomerProduct?: {
|
||
customerProduct?: { product?: { name?: string } };
|
||
updates?: Record<string, unknown>;
|
||
};
|
||
customPrices?: unknown[];
|
||
customEntitlements?: unknown[];
|
||
};
|
||
stripe?: {
|
||
subscriptionAction?: {
|
||
type?: string;
|
||
stripeSubscriptionId?: string;
|
||
params?: {
|
||
items?: Array<{
|
||
id?: string;
|
||
price?: string;
|
||
quantity?: number;
|
||
deleted?: boolean;
|
||
}>;
|
||
trial_end?: number;
|
||
proration_behavior?: string;
|
||
cancel_at_period_end?: boolean;
|
||
};
|
||
};
|
||
subscriptionScheduleAction?: {
|
||
type?: string;
|
||
stripeSubscriptionScheduleId?: string;
|
||
params?: {
|
||
customer?: string;
|
||
start_date?: number | "now";
|
||
end_behavior?: string;
|
||
phases?: Array<{
|
||
start_date?: number;
|
||
end_date?: number;
|
||
trial_end?: number;
|
||
items?: Array<{
|
||
price?: string;
|
||
quantity?: number;
|
||
}>;
|
||
}>;
|
||
};
|
||
};
|
||
invoiceAction?: {
|
||
addLineParams?: {
|
||
lines?: Array<{
|
||
description?: string;
|
||
amount?: number;
|
||
}>;
|
||
};
|
||
};
|
||
invoiceItemsAction?: {
|
||
createInvoiceItems?: Array<{
|
||
description?: string;
|
||
amount?: number;
|
||
customer?: string;
|
||
subscription?: string;
|
||
}>;
|
||
};
|
||
};
|
||
}
|
||
|
||
interface PreviewResultProps {
|
||
data: unknown;
|
||
isLoading: boolean;
|
||
error: Error | null;
|
||
}
|
||
|
||
function PreviewResult({ data, isLoading, error }: PreviewResultProps) {
|
||
const billingPlan = data as BillingPlanData | null;
|
||
const [copied, setCopied] = useState(false);
|
||
|
||
const handleCopy = () => {
|
||
if (data) {
|
||
navigator.clipboard.writeText(JSON.stringify(data, null, 2));
|
||
setCopied(true);
|
||
setTimeout(() => setCopied(false), 2000);
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className="border-b border-border">
|
||
<div className="px-4 py-2 border-b border-border flex items-center justify-between">
|
||
<h3 className="text-sm font-medium">Billing Plan Preview</h3>
|
||
{data ? (
|
||
<button
|
||
type="button"
|
||
onClick={handleCopy}
|
||
className="flex items-center gap-1 text-xs text-t-secondary hover:text-t-primary transition-colors"
|
||
>
|
||
{copied ? (
|
||
<>
|
||
<Check size={14} className="text-green-400" />
|
||
<span className="text-green-400">Copied!</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<Copy size={14} />
|
||
<span>Copy</span>
|
||
</>
|
||
)}
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
|
||
{isLoading ? (
|
||
<div className="px-4 py-3 text-sm text-t-secondary">
|
||
Loading preview...
|
||
</div>
|
||
) : null}
|
||
|
||
{error ? (
|
||
<div className="px-4 py-3 text-sm text-red-400">
|
||
Error: {error.message}
|
||
</div>
|
||
) : null}
|
||
|
||
{!data && !isLoading && !error ? (
|
||
<div className="px-4 py-3 text-sm text-t-secondary">
|
||
No preview data yet
|
||
</div>
|
||
) : null}
|
||
|
||
{billingPlan && !isLoading ? (
|
||
<div className="divide-y divide-border">
|
||
{/* Free Trial Plan */}
|
||
{billingPlan.autumn?.freeTrialPlan ? (
|
||
<div className="px-4 py-3 border-l-2 border-l-purple-500">
|
||
<h4 className="text-xs font-semibold text-purple-400 mb-1">
|
||
🎁 Free Trial Plan
|
||
</h4>
|
||
<div className="text-sm space-y-0.5">
|
||
{billingPlan.autumn.freeTrialPlan.freeTrial ? (
|
||
<div>
|
||
<span className="text-t-secondary">Duration: </span>
|
||
<span className="text-purple-400">
|
||
{billingPlan.autumn.freeTrialPlan.freeTrial.length}{" "}
|
||
{billingPlan.autumn.freeTrialPlan.freeTrial.duration}
|
||
{Number(
|
||
billingPlan.autumn.freeTrialPlan.freeTrial.length,
|
||
) > 1
|
||
? "s"
|
||
: ""}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{billingPlan.autumn.freeTrialPlan.trialEndsAt ? (
|
||
<div>
|
||
<span className="text-t-secondary">Trial ends at: </span>
|
||
{(() => {
|
||
const trialEndsFormatted = formatUnixToDateTime(
|
||
billingPlan.autumn.freeTrialPlan.trialEndsAt,
|
||
);
|
||
return (
|
||
<span className="text-purple-400">
|
||
{trialEndsFormatted.date} {trialEndsFormatted.time}
|
||
</span>
|
||
);
|
||
})()}
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Insert Customer Products */}
|
||
{billingPlan.autumn?.insertCustomerProducts &&
|
||
billingPlan.autumn.insertCustomerProducts.length > 0 ? (
|
||
<div className="px-4 py-3 border-l-2 border-l-green-500">
|
||
<h4 className="text-xs font-semibold text-green-400 mb-1">
|
||
📥 Inserting Customer Product
|
||
</h4>
|
||
{billingPlan.autumn.insertCustomerProducts.map((cp, index) => {
|
||
const startsAtFormatted = formatUnixToDateTime(cp.starts_at);
|
||
const trialEndsAtFormatted = formatUnixToDateTime(
|
||
cp.trial_ends_at,
|
||
);
|
||
return (
|
||
<div key={index} className="text-sm">
|
||
<div className="font-medium">
|
||
{cp.product?.name || "Unknown Product"}
|
||
</div>
|
||
<div className="mt-1 text-xs text-t-secondary space-y-0.5">
|
||
{cp.status ? (
|
||
<div>
|
||
<span className="font-medium">Status: </span>
|
||
<span className="text-amber-400">{cp.status}</span>
|
||
</div>
|
||
) : null}
|
||
{cp.starts_at ? (
|
||
<div>
|
||
<span className="font-medium">Starts at: </span>
|
||
<span className="text-blue-400">
|
||
{startsAtFormatted.date} {startsAtFormatted.time}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{cp.trial_ends_at ? (
|
||
<div>
|
||
<span className="font-medium">Trial ends at: </span>
|
||
<span className="text-purple-400">
|
||
{trialEndsAtFormatted.date}{" "}
|
||
{trialEndsAtFormatted.time}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{cp.customer_entitlements &&
|
||
cp.customer_entitlements.length > 0 ? (
|
||
<div>
|
||
<span className="font-medium">Entitlements:</span>
|
||
<div className="pl-2 mt-0.5 space-y-0.5">
|
||
{cp.customer_entitlements.map((ent, entIndex) => {
|
||
const nextResetFormatted = ent.next_reset_at
|
||
? formatUnixToDateTime(ent.next_reset_at)
|
||
: null;
|
||
return (
|
||
<div
|
||
key={entIndex}
|
||
className="flex items-center gap-2"
|
||
>
|
||
<span className="text-t-primary">
|
||
{ent.entitlement?.feature?.name ||
|
||
ent.feature_id}
|
||
: {ent.balance}
|
||
</span>
|
||
{nextResetFormatted ? (
|
||
<span className="text-t-secondary">
|
||
(resets:{" "}
|
||
<span className="text-blue-400">
|
||
{nextResetFormatted.date}{" "}
|
||
{nextResetFormatted.time}
|
||
</span>
|
||
)
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Update Customer Product */}
|
||
{(() => {
|
||
const updateCusProduct = billingPlan.autumn?.updateCustomerProduct;
|
||
if (!updateCusProduct) return null;
|
||
return (
|
||
<div className="px-4 py-3 border-l-2 border-l-amber-500">
|
||
<h4 className="text-xs font-semibold text-amber-400 mb-1">
|
||
✏️ Updating Customer Product
|
||
</h4>
|
||
<div className="text-sm">
|
||
<div className="font-medium">
|
||
{updateCusProduct.customerProduct?.product?.name ||
|
||
"Unknown Product"}
|
||
</div>
|
||
{updateCusProduct.updates ? (
|
||
<div className="mt-1 text-xs text-t-secondary">
|
||
<span className="font-medium">Updates: </span>
|
||
<code className="text-amber-400">
|
||
{JSON.stringify(updateCusProduct.updates)}
|
||
</code>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
|
||
{/* Stripe Subscription Action */}
|
||
{billingPlan.stripe?.subscriptionAction ? (
|
||
<div className="px-4 py-3 border-l-2 border-l-blue-500">
|
||
<h4 className="text-xs font-semibold text-blue-400 mb-1">
|
||
💳 Stripe Subscription Action
|
||
</h4>
|
||
<div className="text-sm flex items-center gap-3">
|
||
<span>
|
||
<span className="text-t-secondary">Type: </span>
|
||
<span className="font-medium">
|
||
{billingPlan.stripe.subscriptionAction.type || "none"}
|
||
</span>
|
||
</span>
|
||
{billingPlan.stripe.subscriptionAction.stripeSubscriptionId ? (
|
||
<span>
|
||
<span className="text-t-secondary">Sub: </span>
|
||
<code className="text-xs text-blue-400">
|
||
{
|
||
billingPlan.stripe.subscriptionAction
|
||
.stripeSubscriptionId
|
||
}
|
||
</code>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{/* Subscription Items */}
|
||
{billingPlan.stripe.subscriptionAction.params?.items &&
|
||
billingPlan.stripe.subscriptionAction.params.items.length > 0 ? (
|
||
<div className="mt-2 space-y-1">
|
||
<div className="text-xs text-t-secondary font-medium">
|
||
Items:
|
||
</div>
|
||
{billingPlan.stripe.subscriptionAction.params.items.map(
|
||
(item, index) => (
|
||
<div
|
||
key={index}
|
||
className={`flex justify-between text-xs pl-2 border-l ${
|
||
item.deleted
|
||
? "border-red-500/30"
|
||
: "border-blue-500/30"
|
||
}`}
|
||
>
|
||
{item.deleted ? (
|
||
<>
|
||
<span className="text-red-400 font-mono">
|
||
{item.id || "Unknown item"}
|
||
</span>
|
||
<span className="text-red-400">🗑️ delete</span>
|
||
</>
|
||
) : (
|
||
<>
|
||
<span className="text-t-primary font-mono">
|
||
{item.price || item.id || "Unknown"}
|
||
</span>
|
||
<span className="text-blue-400">
|
||
qty: {item.quantity ?? 1}
|
||
</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
),
|
||
)}
|
||
</div>
|
||
) : null}
|
||
{/* Other params */}
|
||
{billingPlan.stripe.subscriptionAction.params?.trial_end ? (
|
||
<div className="mt-1 text-xs text-t-secondary">
|
||
<span>Trial ends: </span>
|
||
<span className="text-green-400">
|
||
{new Date(
|
||
billingPlan.stripe.subscriptionAction.params.trial_end *
|
||
1000,
|
||
).toLocaleDateString()}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{billingPlan.stripe.subscriptionAction.params ? (
|
||
<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.subscriptionAction.params,
|
||
null,
|
||
2,
|
||
)}
|
||
</pre>
|
||
</details>
|
||
) : null}
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Stripe Subscription Schedule Action */}
|
||
{billingPlan.stripe?.subscriptionScheduleAction ? (
|
||
<div className="px-4 py-3 border-l-2 border-l-cyan-500">
|
||
<h4 className="text-xs font-semibold text-cyan-400 mb-1">
|
||
📅 Stripe Subscription Schedule Action
|
||
</h4>
|
||
<div className="text-sm flex items-center gap-3 flex-wrap">
|
||
<span>
|
||
<span className="text-t-secondary">Type: </span>
|
||
<span className="font-medium">
|
||
{billingPlan.stripe.subscriptionScheduleAction.type ||
|
||
"none"}
|
||
</span>
|
||
</span>
|
||
{billingPlan.stripe.subscriptionScheduleAction
|
||
.stripeSubscriptionScheduleId ? (
|
||
<span>
|
||
<span className="text-t-secondary">Schedule: </span>
|
||
<code className="text-xs text-cyan-400">
|
||
{
|
||
billingPlan.stripe.subscriptionScheduleAction
|
||
.stripeSubscriptionScheduleId
|
||
}
|
||
</code>
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
{/* Schedule-level start_date */}
|
||
{(() => {
|
||
const scheduleParams =
|
||
billingPlan.stripe.subscriptionScheduleAction.params;
|
||
if (!scheduleParams) return null;
|
||
const scheduleStartDate =
|
||
typeof scheduleParams.start_date === "number"
|
||
? formatUnixToDateTime(scheduleParams.start_date * 1000)
|
||
: null;
|
||
return (
|
||
<div className="mt-1 text-xs space-y-0.5">
|
||
{scheduleStartDate ? (
|
||
<div>
|
||
<span className="text-t-secondary">Starts: </span>
|
||
<span className="text-cyan-400">
|
||
{scheduleStartDate.date} {scheduleStartDate.time}
|
||
</span>
|
||
</div>
|
||
) : scheduleParams.start_date === "now" ? (
|
||
<div>
|
||
<span className="text-t-secondary">Starts: </span>
|
||
<span className="text-cyan-400">now</span>
|
||
</div>
|
||
) : null}
|
||
{scheduleParams.end_behavior ? (
|
||
<div>
|
||
<span className="text-t-secondary">End behavior: </span>
|
||
<span className="text-amber-400">
|
||
{scheduleParams.end_behavior}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})()}
|
||
{/* Phases */}
|
||
{billingPlan.stripe.subscriptionScheduleAction.params?.phases &&
|
||
billingPlan.stripe.subscriptionScheduleAction.params.phases
|
||
.length > 0 ? (
|
||
<div className="mt-2 space-y-3">
|
||
<div className="text-xs text-t-secondary font-medium">
|
||
Phases:
|
||
</div>
|
||
{billingPlan.stripe.subscriptionScheduleAction.params.phases.map(
|
||
(phase, phaseIndex) => {
|
||
const startDate = phase.start_date
|
||
? formatUnixToDateTime(phase.start_date * 1000)
|
||
: null;
|
||
const endDate = phase.end_date
|
||
? formatUnixToDateTime(phase.end_date * 1000)
|
||
: null;
|
||
const trialEnd = phase.trial_end
|
||
? formatUnixToDateTime(phase.trial_end * 1000)
|
||
: null;
|
||
return (
|
||
<div
|
||
key={phaseIndex}
|
||
className="pl-2 border-l border-cyan-500/30"
|
||
>
|
||
<div className="text-xs font-medium text-cyan-400 mb-1">
|
||
Phase {phaseIndex + 1}
|
||
<span className="font-normal text-t-secondary">
|
||
{" "}
|
||
(
|
||
{startDate
|
||
? `${startDate.date} ${startDate.time}`
|
||
: "schedule start"}
|
||
{" → "}
|
||
{endDate
|
||
? `${endDate.date} ${endDate.time}`
|
||
: "ongoing"}
|
||
)
|
||
</span>
|
||
</div>
|
||
{trialEnd ? (
|
||
<div className="text-xs pl-2 mb-0.5">
|
||
<span className="text-t-secondary">
|
||
Trial ends:{" "}
|
||
</span>
|
||
<span className="text-purple-400">
|
||
{trialEnd.date} {trialEnd.time}
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{phase.items && phase.items.length > 0 ? (
|
||
<div className="space-y-0.5">
|
||
{phase.items.map((item, itemIndex) => (
|
||
<div
|
||
key={itemIndex}
|
||
className="flex justify-between text-xs pl-2"
|
||
>
|
||
<span className="text-t-primary font-mono">
|
||
{item.price || "Unknown price"}
|
||
</span>
|
||
<span className="text-cyan-400">
|
||
qty: {item.quantity ?? 1}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-t-secondary pl-2">
|
||
No items
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
) : null}
|
||
<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.subscriptionScheduleAction.params,
|
||
null,
|
||
2,
|
||
)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Stripe Invoice Action */}
|
||
{billingPlan.stripe?.invoiceAction ? (
|
||
<div className="px-4 py-3 border-l-2 border-l-purple-500">
|
||
<h4 className="text-xs font-semibold text-purple-400 mb-1">
|
||
🧾 Stripe Invoice Action
|
||
</h4>
|
||
|
||
{/* Immediate Line Items (addLineParams) */}
|
||
{billingPlan.stripe.invoiceAction.addLineParams?.lines &&
|
||
billingPlan.stripe.invoiceAction.addLineParams.lines.length >
|
||
0 ? (
|
||
<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(
|
||
(
|
||
line: {
|
||
description?: string;
|
||
amount?: number;
|
||
},
|
||
index: number,
|
||
) => {
|
||
const amount = line.amount
|
||
? stripeToAtmnAmount({
|
||
amount: line.amount,
|
||
currency: "usd",
|
||
})
|
||
: 0;
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="flex justify-between text-xs"
|
||
>
|
||
<span className="text-t-primary">
|
||
{line.description || "Line item"}
|
||
</span>
|
||
<span
|
||
className={
|
||
amount >= 0 ? "text-green-400" : "text-red-400"
|
||
}
|
||
>
|
||
${amount.toFixed(2)}
|
||
</span>
|
||
</div>
|
||
);
|
||
},
|
||
)}
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="text-xs text-t-secondary mb-2">
|
||
No immediate line items
|
||
</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.invoiceAction, null, 2)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
) : 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 */}
|
||
{billingPlan.stripe &&
|
||
!billingPlan.stripe.subscriptionAction &&
|
||
!billingPlan.stripe.invoiceAction &&
|
||
!billingPlan.stripe.invoiceItemsAction ? (
|
||
<div className="px-4 py-2 text-xs text-t-secondary">
|
||
No Stripe actions required
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Raw JSON toggle */}
|
||
<details className="px-4 py-2 text-xs">
|
||
<summary className="cursor-pointer text-t-secondary hover:text-t-primary">
|
||
View raw JSON
|
||
</summary>
|
||
<pre className="bg-t-50 p-2 rounded mt-1 overflow-auto max-h-40">
|
||
{JSON.stringify(data, null, 2)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
interface UpdateResultProps {
|
||
data: unknown;
|
||
isLoading: boolean;
|
||
error: Error | null;
|
||
}
|
||
|
||
function UpdateResult({ data, isLoading, error }: UpdateResultProps) {
|
||
if (!data && !isLoading && !error) return null;
|
||
|
||
return (
|
||
<div className="border-b border-border">
|
||
<div className="px-4 py-2 border-b border-border">
|
||
<h3 className="text-sm font-medium">Update Response</h3>
|
||
</div>
|
||
{isLoading ? (
|
||
<div className="px-4 py-3 text-sm text-t-secondary">Updating...</div>
|
||
) : null}
|
||
{error ? (
|
||
<div className="px-4 py-3 text-sm text-red-400">
|
||
Error: {error.message}
|
||
</div>
|
||
) : null}
|
||
{data !== null && data !== undefined && !isLoading ? (
|
||
<div className="px-4 py-3 border-l-2 border-l-green-500">
|
||
<div className="text-xs text-green-400 mb-1">✓ Success</div>
|
||
<pre className="text-xs bg-t-50 p-2 rounded overflow-auto max-h-60">
|
||
{JSON.stringify(data, null, 2)}
|
||
</pre>
|
||
</div>
|
||
) : null}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function useSubscriptionUpdatePreview({
|
||
body,
|
||
enabled,
|
||
}: {
|
||
body: UpdateSubscriptionV0Params | null;
|
||
enabled: boolean;
|
||
}) {
|
||
const axiosInstance = useAxiosInstance();
|
||
|
||
// Debounce the body to avoid too many API calls
|
||
const [debouncedBody, setDebouncedBody] = useState(body);
|
||
|
||
useEffect(() => {
|
||
const timer = setTimeout(() => {
|
||
setDebouncedBody(body);
|
||
}, 300);
|
||
return () => clearTimeout(timer);
|
||
}, [body]);
|
||
|
||
const isDebouncing = JSON.stringify(body) !== JSON.stringify(debouncedBody);
|
||
|
||
const query = useQuery({
|
||
queryKey: [
|
||
"subscription-update-preview-test",
|
||
JSON.stringify(debouncedBody),
|
||
],
|
||
queryFn: async () => {
|
||
if (!debouncedBody) return null;
|
||
const response = await axiosInstance.post(
|
||
"/v1/subscriptions/preview_update",
|
||
debouncedBody,
|
||
);
|
||
return response.data;
|
||
},
|
||
enabled: enabled && !!debouncedBody,
|
||
retry: false,
|
||
});
|
||
|
||
return {
|
||
...query,
|
||
isLoading: query.isLoading || isDebouncing,
|
||
};
|
||
}
|
||
|
||
interface SubscriptionUpdateParams {
|
||
body: UpdateSubscriptionV0Params;
|
||
useInvoice?: boolean;
|
||
enableProductImmediately?: boolean;
|
||
}
|
||
|
||
function useSubscriptionUpdate({
|
||
customerId,
|
||
onInvoiceCreated,
|
||
}: {
|
||
customerId?: string;
|
||
onInvoiceCreated?: (invoiceLink: string) => void;
|
||
}) {
|
||
const axiosInstance = useAxiosInstance();
|
||
const queryClient = useQueryClient();
|
||
const { closeSheet } = useSheetStore();
|
||
|
||
return useMutation({
|
||
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(
|
||
"/v1/subscriptions/update",
|
||
requestBody,
|
||
);
|
||
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);
|
||
},
|
||
});
|
||
}
|
||
|
||
function SheetContent({
|
||
cusProduct,
|
||
productV2,
|
||
customizedProduct,
|
||
}: {
|
||
cusProduct: FullCusProduct;
|
||
productV2: ProductV2;
|
||
customizedProduct: FrontendProduct | undefined;
|
||
}) {
|
||
const navigate = useNavigate();
|
||
const { customer, features } = useCusQuery();
|
||
const customerId = customer?.id ?? customer?.internal_id;
|
||
const entityId = cusProduct?.entity_id ?? undefined;
|
||
|
||
// Stripe + invoice handling
|
||
const { stripeAccount } = useOrgStripeQuery();
|
||
const env = useEnv();
|
||
|
||
const product = customizedProduct?.id ? customizedProduct : productV2;
|
||
const { prepaidItems } = usePrepaidItems({ product });
|
||
|
||
// Get display info for custom items
|
||
const getItemDisplay = (item: ProductItem) => {
|
||
return getProductItemDisplay({
|
||
item,
|
||
features: (features as Feature[]) ?? [],
|
||
currency: "usd",
|
||
});
|
||
};
|
||
|
||
// Handle Edit Plan - navigates to plan editor
|
||
const handleEditPlan = () => {
|
||
if (!cusProduct || !customer) return;
|
||
|
||
const entity = (customer as FullCustomer).entities?.find(
|
||
(e: Entity) =>
|
||
e.internal_id === cusProduct.internal_entity_id ||
|
||
e.id === cusProduct.entity_id,
|
||
);
|
||
|
||
pushPage({
|
||
path: `/customers/${customer.id || customer.internal_id}/${cusProduct.product_id}`,
|
||
queryParams: {
|
||
id: cusProduct.id,
|
||
entity_id: entity ? entity.id || entity.internal_id : undefined,
|
||
version: String(cusProduct.product.version),
|
||
},
|
||
navigate,
|
||
});
|
||
};
|
||
|
||
// Get initial prepaid values from the current subscription
|
||
const initialPrepaidOptions = useMemo(() => {
|
||
return cusProduct.options.reduce(
|
||
(acc, option) => {
|
||
acc[option.feature_id] = option.quantity;
|
||
return acc;
|
||
},
|
||
{} as Record<string, number>,
|
||
);
|
||
}, [cusProduct.options]);
|
||
|
||
const [prepaidOptions, setPrepaidOptions] = useState<Record<string, number>>(
|
||
initialPrepaidOptions,
|
||
);
|
||
|
||
const [planCustomStartDate, setPlanCustomStartDate] = useState<number | null>(
|
||
null,
|
||
);
|
||
const [planCustomEndDate, setPlanCustomEndDate] = useState<number | null>(
|
||
null,
|
||
);
|
||
const [billingCycleAnchor, setBillingCycleAnchor] = useState<number | null>(
|
||
null,
|
||
);
|
||
|
||
const handlePrepaidChange = (featureId: string, quantity: number) => {
|
||
setPrepaidOptions((prev) => ({
|
||
...prev,
|
||
[featureId]: quantity,
|
||
}));
|
||
};
|
||
|
||
// Build the request body
|
||
const requestBody = useMemo<UpdateSubscriptionV0Params | null>(() => {
|
||
if (!customerId) return null;
|
||
|
||
const body: UpdateSubscriptionV0Params = {
|
||
customer_id: customerId,
|
||
product_id: product?.id,
|
||
entity_id: entityId,
|
||
customer_product_id: cusProduct.id ?? cusProduct.internal_product_id,
|
||
};
|
||
|
||
// Add options only if they have changed from initial values
|
||
if (prepaidItems.length > 0) {
|
||
const options = prepaidItems
|
||
.map((item) => {
|
||
const featureId = item.feature_id ?? item.feature?.internal_id ?? "";
|
||
const quantity = prepaidOptions[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 null;
|
||
})
|
||
.filter(Boolean);
|
||
|
||
if (options.length > 0) {
|
||
body.options = options as Array<{
|
||
feature_id: string;
|
||
quantity: number;
|
||
}>;
|
||
}
|
||
}
|
||
|
||
// Add custom items if we have a customized product
|
||
if (customizedProduct?.items) {
|
||
body.items = customizedProduct.items;
|
||
}
|
||
|
||
// Add free trial if we have a customized product with free trial
|
||
if (customizedProduct?.free_trial) {
|
||
body.free_trial = customizedProduct.free_trial;
|
||
}
|
||
|
||
// Add custom plan dates if set (epoch milliseconds)
|
||
if (planCustomStartDate) {
|
||
body.plan_custom_start_date = planCustomStartDate;
|
||
}
|
||
if (planCustomEndDate) {
|
||
body.plan_custom_end_date = planCustomEndDate;
|
||
}
|
||
if (billingCycleAnchor) {
|
||
body.billing_cycle_anchor = billingCycleAnchor;
|
||
}
|
||
|
||
return body;
|
||
}, [
|
||
customerId,
|
||
product?.id,
|
||
entityId,
|
||
cusProduct.id,
|
||
cusProduct.internal_product_id,
|
||
prepaidItems,
|
||
prepaidOptions,
|
||
initialPrepaidOptions,
|
||
customizedProduct?.items,
|
||
customizedProduct?.free_trial,
|
||
planCustomStartDate,
|
||
planCustomEndDate,
|
||
billingCycleAnchor,
|
||
]);
|
||
|
||
// Preview query - fires when body changes
|
||
const previewQuery = useSubscriptionUpdatePreview({
|
||
body: requestBody,
|
||
enabled: !!requestBody,
|
||
});
|
||
|
||
// Update mutation with invoice handling
|
||
const updateMutation = useSubscriptionUpdate({
|
||
customerId,
|
||
onInvoiceCreated: (stripeInvoice) => {
|
||
const invoiceLink = getStripeInvoiceLink({
|
||
stripeInvoice,
|
||
env,
|
||
accountId: stripeAccount?.id,
|
||
});
|
||
window.open(invoiceLink, "_blank");
|
||
},
|
||
});
|
||
|
||
const handleConfirm = () => {
|
||
if (!requestBody) return;
|
||
updateMutation.mutate({ body: requestBody, useInvoice: false });
|
||
};
|
||
|
||
const handleInvoiceUpdate = ({
|
||
enableProductImmediately,
|
||
}: {
|
||
enableProductImmediately: boolean;
|
||
}) => {
|
||
if (!requestBody) return;
|
||
updateMutation.mutate({
|
||
body: requestBody,
|
||
useInvoice: true,
|
||
enableProductImmediately,
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<SheetHeader
|
||
title="Subscription Update Test"
|
||
description={`Testing update for ${cusProduct.product.name}`}
|
||
>
|
||
<IconButton
|
||
variant="primary"
|
||
onClick={handleEditPlan}
|
||
icon={<PencilSimple size={16} weight="duotone" />}
|
||
>
|
||
Edit Plan
|
||
</IconButton>
|
||
</SheetHeader>
|
||
|
||
<div className="flex-1 overflow-y-auto">
|
||
{/* Current Customer Product Options */}
|
||
{cusProduct.options && cusProduct.options.length > 0 ? (
|
||
<div className="border-b border-border">
|
||
<div className="px-4 py-2 border-b border-border">
|
||
<h3 className="text-sm font-medium">Current Options</h3>
|
||
</div>
|
||
<div className="px-4 py-3 space-y-1">
|
||
{cusProduct.options.map((option) => (
|
||
<div
|
||
key={option.feature_id}
|
||
className="flex justify-between text-sm"
|
||
>
|
||
<span className="text-t-secondary">{option.feature_id}</span>
|
||
<span className="font-mono">{option.quantity}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
{/* Request Body Display */}
|
||
<div className="border-b border-border">
|
||
<div className="px-4 py-2 border-b border-border">
|
||
<h3 className="text-sm font-medium">Request Body</h3>
|
||
</div>
|
||
<div className="px-4 py-3 text-sm space-y-1">
|
||
<div>
|
||
<span className="text-t-secondary">customer_id: </span>
|
||
<code className="text-xs">{requestBody?.customer_id}</code>
|
||
</div>
|
||
<div>
|
||
<span className="text-t-secondary">product_id: </span>
|
||
<code className="text-xs">{requestBody?.product_id}</code>
|
||
</div>
|
||
{requestBody?.entity_id ? (
|
||
<div>
|
||
<span className="text-t-secondary">entity_id: </span>
|
||
<code className="text-xs">{requestBody.entity_id}</code>
|
||
</div>
|
||
) : null}
|
||
{requestBody?.options && requestBody.options.length > 0 ? (
|
||
<div>
|
||
<span className="text-t-secondary">options: </span>
|
||
<code className="text-xs">
|
||
{requestBody.options
|
||
.map((o) => `${o.feature_id}: ${o.quantity}`)
|
||
.join(", ")}
|
||
</code>
|
||
</div>
|
||
) : null}
|
||
{requestBody?.items && requestBody.items.length > 0 ? (
|
||
<div>
|
||
<span className="text-t-secondary">items: </span>
|
||
<div className="mt-1 pl-3 space-y-1">
|
||
{requestBody.items.map((item, index) => {
|
||
const display = getItemDisplay(item as ProductItem);
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="text-xs border-l-2 border-l-purple-500 pl-2"
|
||
>
|
||
<span className="text-t-primary">
|
||
{display.primary_text}
|
||
</span>
|
||
{display.secondary_text ? (
|
||
<span className="text-t-secondary ml-1">
|
||
{display.secondary_text}
|
||
</span>
|
||
) : null}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{requestBody?.free_trial ? (
|
||
<div>
|
||
<span className="text-t-secondary">free_trial: </span>
|
||
<span className="text-xs">
|
||
<span className="text-green-400">
|
||
{requestBody.free_trial.length}{" "}
|
||
{requestBody.free_trial.duration}
|
||
{Number(requestBody.free_trial.length) > 1 ? "s" : ""}
|
||
</span>
|
||
<span className="text-t-secondary ml-2">
|
||
(card_required:{" "}
|
||
{requestBody.free_trial.card_required ? "true" : "false"})
|
||
</span>
|
||
</span>
|
||
</div>
|
||
) : null}
|
||
{requestBody?.plan_custom_start_date ? (
|
||
<div>
|
||
<span className="text-t-secondary">
|
||
plan_custom_start_date:{" "}
|
||
</span>
|
||
<code className="text-xs text-amber-400">
|
||
{requestBody.plan_custom_start_date}
|
||
</code>
|
||
</div>
|
||
) : null}
|
||
{requestBody?.plan_custom_end_date ? (
|
||
<div>
|
||
<span className="text-t-secondary">plan_custom_end_date: </span>
|
||
<code className="text-xs text-amber-400">
|
||
{requestBody.plan_custom_end_date}
|
||
</code>
|
||
</div>
|
||
) : null}
|
||
{requestBody?.billing_cycle_anchor ? (
|
||
<div>
|
||
<span className="text-t-secondary">billing_cycle_anchor: </span>
|
||
<code className="text-xs text-cyan-400">
|
||
{requestBody.billing_cycle_anchor}
|
||
</code>
|
||
</div>
|
||
) : null}
|
||
<details className="mt-2">
|
||
<summary className="text-xs cursor-pointer text-t-secondary hover:text-t-primary">
|
||
View raw JSON
|
||
</summary>
|
||
<pre className="text-xs bg-t-50 p-2 rounded mt-1 overflow-auto max-h-32">
|
||
{JSON.stringify(requestBody, null, 2)}
|
||
</pre>
|
||
</details>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Prepaid Editor */}
|
||
<PrepaidEditor
|
||
prepaidItems={prepaidItems}
|
||
prepaidOptions={prepaidOptions}
|
||
onPrepaidChange={handlePrepaidChange}
|
||
/>
|
||
|
||
{/* Custom Plan Dates */}
|
||
<div className="border-b border-border">
|
||
<div className="px-4 py-2 border-b border-border">
|
||
<h3 className="text-sm font-medium">Custom Plan Dates</h3>
|
||
</div>
|
||
<div className="px-4 py-3 space-y-3">
|
||
<div className="flex items-center gap-3">
|
||
<label
|
||
htmlFor="plan-start-date"
|
||
className="text-sm text-t-secondary w-32"
|
||
>
|
||
Start Date
|
||
</label>
|
||
<div className="flex-1">
|
||
<DateInputUnix
|
||
unixDate={planCustomStartDate}
|
||
setUnixDate={setPlanCustomStartDate}
|
||
/>
|
||
</div>
|
||
{planCustomStartDate ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlanCustomStartDate(null)}
|
||
className="text-xs text-t-secondary hover:text-t-primary"
|
||
>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<label
|
||
htmlFor="plan-end-date"
|
||
className="text-sm text-t-secondary w-32"
|
||
>
|
||
End Date
|
||
</label>
|
||
<div className="flex-1">
|
||
<DateInputUnix
|
||
unixDate={planCustomEndDate}
|
||
setUnixDate={setPlanCustomEndDate}
|
||
/>
|
||
</div>
|
||
{planCustomEndDate ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlanCustomEndDate(null)}
|
||
className="text-xs text-t-secondary hover:text-t-primary"
|
||
>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
<label
|
||
htmlFor="billing-cycle-anchor"
|
||
className="text-sm text-t-secondary w-32"
|
||
>
|
||
Billing Anchor
|
||
</label>
|
||
<div className="flex-1">
|
||
<DateInputUnix
|
||
unixDate={billingCycleAnchor}
|
||
setUnixDate={setBillingCycleAnchor}
|
||
/>
|
||
</div>
|
||
{billingCycleAnchor ? (
|
||
<button
|
||
type="button"
|
||
onClick={() => setBillingCycleAnchor(null)}
|
||
className="text-xs text-t-secondary hover:text-t-primary"
|
||
>
|
||
Clear
|
||
</button>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Preview Result */}
|
||
<PreviewResult
|
||
data={previewQuery.data}
|
||
isLoading={previewQuery.isLoading}
|
||
error={previewQuery.error as Error | null}
|
||
/>
|
||
|
||
{/* Update Result */}
|
||
<UpdateResult
|
||
data={updateMutation.data}
|
||
isLoading={updateMutation.isPending}
|
||
error={updateMutation.error as Error | null}
|
||
/>
|
||
</div>
|
||
|
||
{/* Footer Actions */}
|
||
<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
|
||
variant="primary"
|
||
className="w-full"
|
||
onClick={handleConfirm}
|
||
disabled={!requestBody || updateMutation.isPending}
|
||
>
|
||
{updateMutation.isPending ? "Updating..." : "Confirm Update"}
|
||
</Button>
|
||
|
||
{/* Refresh Preview Button */}
|
||
<Button
|
||
variant="secondary"
|
||
className="w-full"
|
||
onClick={() => previewQuery.refetch()}
|
||
disabled={!requestBody || previewQuery.isLoading}
|
||
>
|
||
Refresh Preview
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Main sheet component.
|
||
*
|
||
* To use this sheet, you need to:
|
||
* 1. Add "subscription-update-test" to the SheetType union in useSheetStore.ts
|
||
* 2. Add a case for it in CustomerSheets.tsx
|
||
* 3. Or, for quick testing, temporarily replace SubscriptionUpdateSheet import
|
||
*
|
||
* Example trigger:
|
||
* setSheet({
|
||
* type: "subscription-update-test",
|
||
* itemId: cusProduct.id,
|
||
* data: { customizedProduct: product } // optional
|
||
* })
|
||
*/
|
||
export function SubscriptionUpdateTestSheet() {
|
||
const itemId = useSheetStore((s) => s.itemId);
|
||
const sheetData = useSheetStore((s) => s.data);
|
||
|
||
const { cusProduct, productV2 } = useSubscriptionById({ itemId });
|
||
|
||
const customizedProduct = sheetData?.customizedProduct as
|
||
| FrontendProduct
|
||
| undefined;
|
||
|
||
if (!cusProduct) {
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<SheetHeader
|
||
title="Subscription Update Test"
|
||
description="Loading..."
|
||
/>
|
||
<div className="p-4 text-sm text-t-secondary">
|
||
No customer product found for itemId: {itemId}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!productV2) {
|
||
return (
|
||
<div className="flex flex-col h-full">
|
||
<SheetHeader
|
||
title="Subscription Update Test"
|
||
description="Loading product..."
|
||
/>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<SheetContent
|
||
cusProduct={cusProduct}
|
||
productV2={productV2}
|
||
customizedProduct={customizedProduct}
|
||
/>
|
||
);
|
||
}
|