Compare commits

...

2 commits

Author SHA1 Message Date
le king fu
a8c3fd6997 refactor(gating): one constant for the split's feature key
All checks were successful
PR Check — Frontend / frontend (pull_request) Successful in 1m56s
Review of PR #452 (comment 24129): "adjustments" was written three
times for the split (the page's gate, the button's edition, the
dialog's locked screen). SPLIT_FEATURE in splitGate.ts is now the one
source, so moving the split to its own key or to Premium is a one-line
change. No behaviour change; setting it to "budget" turns 2 tests red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
le king fu
d5d0e89d2c test(gating): pin that one click never arms the other window
Review of PR #452 (comment 24129): the upgrade to Base checked that the
upsell goes away, not that no split window appears in its place, so a
locked click that also armed the split window (R14) left the suite
green. Both edition changes now assert the other window stays closed,
during the license refresh and after it.

R14 now turns 1 test red, and its mirror (a split click that also arms
the upsell) 1 test red.

Refs #369

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 07:36:33 -04:00
5 changed files with 29 additions and 12 deletions

View file

@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Lock, Split } from "lucide-react";
import type { TransactionRow } from "../../shared/types";
import { requiredTierFor } from "../../shared/entitlements";
import type { SplitAccess } from "../../shared/splitGate";
import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate";
interface SplitButtonProps {
row: TransactionRow;
@ -40,7 +40,7 @@ export default function SplitButton({ row, access, onOpenSplit, onOpenUpsell }:
title={
locked
? t("transactions.splitLocked", {
tier: t(`license.editions.${requiredTierFor("adjustments")}`),
tier: t(`license.editions.${requiredTierFor(SPLIT_FEATURE)}`),
})
: t("transactions.splitAdjustment")
}

View file

@ -8,7 +8,7 @@ import type {
SplitChild,
} from "../../shared/types";
import type { LinkedTransferTooltipRow } from "../../services/balance.service";
import type { SplitAccess } from "../../shared/splitGate";
import { SPLIT_FEATURE, type SplitAccess } from "../../shared/splitGate";
import { onboardingTargetAttributes } from "../../utils/onboardingPath";
import CategoryCombobox from "../shared/CategoryCombobox";
import UpsellDialog from "../shared/UpsellDialog";
@ -28,7 +28,7 @@ interface TransactionTableProps {
onDeleteSplit: (parentId: number) => Promise<void>;
/**
* What the Répartition button does (#369): `splitAccessFor` over
* `useEntitlement("adjustments")`. Required on purpose: a default of "open"
* `useEntitlement(SPLIT_FEATURE)`. Required on purpose: a default of "open"
* would let a new caller leave the split ungated without a word.
*/
splitAccess: SplitAccess;
@ -351,7 +351,7 @@ export default function TransactionTable({
)}
{/* Symmetric: the upsell goes away once the edition resolves to Base or Premium. */}
{showSplitUpsell && splitAccess !== "open" && (
<UpsellDialog feature="adjustments" onClose={() => setShowSplitUpsell(false)} />
<UpsellDialog feature={SPLIT_FEATURE} onClose={() => setShowSplitUpsell(false)} />
)}
</div>
);

View file

@ -119,15 +119,24 @@ describe("TransactionTable — the Répartition button's wiring (#369)", () => {
expect(upsells(opened)).toHaveLength(0);
});
// One click arms one window: an edition change never brings up the other one.
it("an open split window survives a license refresh and closes once the edition is Free", () => {
(splitButtons(render("open"))[0].props.onOpenSplit as () => void)();
expect(splitWindows(render("pending"))).toHaveLength(1);
expect(splitWindows(render("locked"))).toHaveLength(0);
const refreshing = render("pending");
expect(splitWindows(refreshing)).toHaveLength(1);
expect(upsells(refreshing)).toHaveLength(0);
const downgraded = render("locked");
expect(splitWindows(downgraded)).toHaveLength(0);
expect(upsells(downgraded)).toHaveLength(0);
});
it("an open upsell survives a license refresh and goes away once the edition is Base", () => {
(splitButtons(render("locked"))[0].props.onOpenUpsell as () => void)();
expect(upsells(render("pending"))).toHaveLength(1);
expect(upsells(render("open"))).toHaveLength(0);
const refreshing = render("pending");
expect(upsells(refreshing)).toHaveLength(1);
expect(splitWindows(refreshing)).toHaveLength(0);
const upgraded = render("open");
expect(upsells(upgraded)).toHaveLength(0);
expect(splitWindows(upgraded)).toHaveLength(0);
});
});

View file

@ -7,7 +7,7 @@ import EmptyState from "../components/shared/EmptyState";
import OnboardingStepCard from "../components/onboarding/OnboardingStepCard";
import { useOnboarding } from "../contexts/OnboardingContext";
import { useEntitlement } from "../hooks/useEntitlement";
import { splitAccessFor } from "../shared/splitGate";
import { SPLIT_FEATURE, splitAccessFor } from "../shared/splitGate";
import {
readOnboardingNavState,
showsStepCard,
@ -44,7 +44,7 @@ export default function TransactionsPage() {
const onboarding = useOnboarding();
// The Répartition button belongs to the Base edition (#369): locked with the
// upsell on Free, inert while the license is not ready. The page itself stays Free.
const splitGate = useEntitlement("adjustments");
const splitGate = useEntitlement(SPLIT_FEATURE);
const [resultMessage, setResultMessage] = useState<string | null>(null);
const [menu, setMenu] = useState<{ x: number; y: number; row: TransactionRow } | null>(null);
const [pending, setPending] = useState<TransactionRow | null>(null);

View file

@ -19,11 +19,19 @@
* license refresh — never locked, never open on a fail-closed `allowed`.
*/
import type { EntitlementGate } from "./profileGate";
import type { FeatureKey } from "./entitlements";
/**
* The feature the split belongs to. The page reads its gate, the button names
* its edition and the dialog shows its locked screen from this one constant, so
* moving the split (to its own key, to Premium) is a one-line change.
*/
export const SPLIT_FEATURE: FeatureKey = "adjustments";
/** What the split button does: open the split window, show the upsell, or nothing yet. */
export type SplitAccess = "open" | "locked" | "pending";
/** The split button's access, from the gate `useEntitlement("adjustments")` returns. */
/** The split button's access, from the gate `useEntitlement(SPLIT_FEATURE)` returns. */
export function splitAccessFor(gate: EntitlementGate): SplitAccess {
if (!gate.ready) return "pending";
return gate.allowed ? "open" : "locked";