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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions __tests__/billing.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import {
addInterval,
countsTowardSpend,
firstChargeDate,
getCycleLabel,
getDaysUntilRenewal,
getMonthlyEquivalent,
Expand All @@ -8,9 +10,20 @@ import {
pendingRenewal,
reconcileConfirmedThrough,
resolveNextRenewal,
trialPendingConversion,
} from "@/lib/billing";
import dayjs from "dayjs";

const sub = (overrides: Partial<Subscription> = {}): Subscription => ({
id: "s1",
name: "Uber One",
price: 9.99,
billing: "Monthly",
billingCycle: "monthly",
status: "active",
...overrides,
});

describe("normalizeBillingCycle", () => {
it("maps legacy labels", () => {
expect(normalizeBillingCycle("Yearly")).toBe("annual");
Expand Down Expand Up @@ -289,3 +302,44 @@ describe("getCycleLabel", () => {
expect(getCycleLabel("custom", 45)).toBe("Every 45 days");
});
});

describe("trial helpers", () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(new Date("2026-07-16T12:00:00Z"));
});
afterEach(() => jest.useRealTimers());

it("firstChargeDate uses trial end for a trial, else the start date", () => {
expect(
firstChargeDate(
sub({ isTrial: true, trialEndDate: "2026-07-23T00:00:00.000Z", startDate: "2026-07-16T00:00:00.000Z" }),
),
).toBe("2026-07-23T00:00:00.000Z");
expect(
firstChargeDate(sub({ isTrial: false, startDate: "2026-07-16T00:00:00.000Z" })),
).toBe("2026-07-16T00:00:00.000Z");
});

it("trialPendingConversion is true only once the trial end has arrived", () => {
// ends in a week → still running.
expect(
trialPendingConversion(sub({ isTrial: true, trialEndDate: "2026-07-23T00:00:00.000Z" })),
).toBe(false);
// ended today / in the past → needs a decision.
expect(
trialPendingConversion(sub({ isTrial: true, trialEndDate: "2026-07-16T00:00:00.000Z" })),
).toBe(true);
expect(
trialPendingConversion(sub({ isTrial: true, trialEndDate: "2026-07-10T00:00:00.000Z" })),
).toBe(true);
// not a trial → never pending.
expect(trialPendingConversion(sub({ isTrial: false }))).toBe(false);
});

it("countsTowardSpend excludes trials and inactive subs", () => {
expect(countsTowardSpend(sub({ isTrial: false, status: "active" }))).toBe(true);
expect(countsTowardSpend(sub({ isTrial: true, status: "active" }))).toBe(false);
expect(countsTowardSpend(sub({ isTrial: false, status: "paused" }))).toBe(false);
expect(countsTowardSpend(sub({ isTrial: false, status: "cancelled" }))).toBe(false);
});
});
19 changes: 17 additions & 2 deletions __tests__/migrations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,14 @@ describe("MIGRATIONS list", () => {
it("adds the confirmed_through column at v3", () => {
const v3 = MIGRATIONS.find((m) => m.version === 3);
expect(v3?.sql).toMatch(/confirmed_through/);
expect(latest).toBe(3);
});

it("adds the duplicate_acknowledged column at v4", () => {
const v4 = MIGRATIONS.find((m) => m.version === 4);
// Full column shape: a boolean-as-INTEGER that defaults to 0 so existing
// rows are non-acknowledged (still eligible for duplicate flagging).
expect(v4?.sql).toMatch(/duplicate_acknowledged\s+INTEGER\s+NOT NULL\s+DEFAULT 0/);
expect(latest).toBe(4);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});

Expand Down Expand Up @@ -90,12 +97,12 @@ describe("rowToSubscription date_assumed mapping", () => {
notes: null,
status: "active",
price: 15.49,
currency: "USD",
billing_cycle: "monthly",
custom_interval_days: null,
is_trial: 0,
date_assumed: 0,
confirmed_through: null,
duplicate_acknowledged: 0,
trial_end_date: null,
start_date: null,
next_renewal_date: null,
Expand All @@ -112,4 +119,12 @@ describe("rowToSubscription date_assumed mapping", () => {
true,
);
});

it("maps duplicate_acknowledged 0 -> false and 1 -> true", () => {
expect(rowToSubscription(baseRow).duplicateAcknowledged).toBe(false);
expect(
rowToSubscription({ ...baseRow, duplicate_acknowledged: 1 })
.duplicateAcknowledged,
).toBe(true);
});
});
9 changes: 7 additions & 2 deletions __tests__/reminders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,23 @@ describe("buildReminders", () => {
]);
});

it("adds T-2 and T-0 trial reminders for trials", () => {
it("adds T-2 and T-0 trial reminders and suppresses renewal ones during a trial", () => {
const reminders = buildReminders(
baseSub({
isTrial: true,
trialEndDate: "2026-07-16T10:00:00.000Z",
renewalDate: "2026-09-01T10:00:00.000Z",
renewalDate: "2026-07-16T10:00:00.000Z",
}),
"USD",
);
const ids = reminders.map((r) => r.id);
expect(ids).toContain("sub1::trial_2");
expect(ids).toContain("sub1::trial_0");
// On a trial we rely on the trial reminders + in-app conversion check-in,
// not the generic renewal reminders — otherwise they'd cluster on one day.
expect(ids).not.toContain("sub1::renewal_3");
expect(ids).not.toContain("sub1::renewal_1");
expect(ids).not.toContain("sub1::checkin");
});

it("returns nothing for non-active subscriptions", () => {
Expand Down
3 changes: 2 additions & 1 deletion app.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,8 @@
],
"expo-localization",
"expo-sqlite",
"@react-native-community/datetimepicker"
"@react-native-community/datetimepicker",
"expo-asset"
],
"experiments": {
"typedRoutes": true,
Expand Down
89 changes: 60 additions & 29 deletions app/(tabs)/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AnimatedCounter from "@/components/AnimatedCounter";
import { FadeInUp, PressableScale } from "@/components/motion";
import PulsingDot from "@/components/PulsingDot";
import ListHeading from "@/components/ListHeading";
import SubscriptionIcon from "@/components/SubscriptionIcon";
import SubscriptionFormModal from "@/components/SubscriptionFormModal";
import UpcomingSubscriptionCard from "@/components/UpcomingSubscriptionCard";
import { icons } from "@/constants/icons";
Expand All @@ -22,7 +23,7 @@ import { formatCurrency } from "@/lib/utils";
import { useClerk, useUser } from "@clerk/expo";
import { styled } from "nativewind";
import { usePostHog } from "posthog-react-native";
import { useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
Animated,
FlatList,
Expand All @@ -32,15 +33,23 @@ import {
Text,
View,
} from "react-native";
import { useRouter } from "expo-router";
import { useFocusEffect, useRouter } from "expo-router";

import { SafeAreaView as RNSafeAreaView } from "react-native-safe-area-context";
const SafeAreaView = styled(RNSafeAreaView) as any;

export default function App() {
const { user, isSignedIn } = useUser();
const { signOut } = useClerk();
const { subscriptions, addSubscription } = useSubscriptions();
const { subscriptions, addSubscription, refresh } = useSubscriptions();

// Re-pull from the DB whenever Home regains focus, so nudge counts reflect
// actions taken on the detail screen (confirm renewal, delete duplicate, etc).
useFocusEffect(
useCallback(() => {
refresh();
}, [refresh]),
);
const { baseCurrency } = useCurrency();
const posthog = usePostHog();
const router = useRouter();
Expand Down Expand Up @@ -114,18 +123,22 @@ export default function App() {

// Real monthly outflow across mixed billing cycles (annual/quarterly/etc.
// are normalized to a per-month average, so the total reflects everything).
// Free trials cost nothing until they convert, so they don't count toward
// current spend (they surface as a conversion check-in instead).
const monthlyTotal = useMemo(
() =>
activeSubscriptions.reduce(
(total, sub) =>
total +
getMonthlyEquivalent(
sub.price,
sub.billingCycle ?? "monthly",
sub.customIntervalDays,
),
0,
),
activeSubscriptions
.filter((sub) => !sub.isTrial)
.reduce(
(total, sub) =>
total +
getMonthlyEquivalent(
sub.price,
sub.billingCycle ?? "monthly",
sub.customIntervalDays,
),
0,
),
[activeSubscriptions],
);
const yearlyTotal = monthlyTotal * 12;
Expand All @@ -137,7 +150,6 @@ export default function App() {
id: subscription.id,
name: subscription.name,
price: subscription.price,
currency: subscription.currency,
daysLeft:
getDaysUntilRenewal(
subscription.renewalDate ?? subscription.startDate,
Expand Down Expand Up @@ -367,23 +379,42 @@ export default function App() {
</Text>
</View>
) : (
<Pressable
onPress={() => router.push("/subscriptions")}
className="flex-row items-center justify-between rounded-2xl border border-border bg-card p-4"
>
<View>
<Text className="text-base font-sans-semibold text-primary">
Your subscriptions
</Text>
<Text className="mt-0.5 text-sm font-sans-medium text-muted-foreground">
{activeSubscriptions.length} active · {subscriptions.length}{" "}
total
<PressableScale onPress={() => router.push("/subscriptions")}>
<View className="flex-row items-center justify-between rounded-2xl border border-border bg-card p-4">
<View className="min-w-0 flex-1 flex-row items-center gap-3">
<View className="flex-row">
{activeSubscriptions.slice(0, 4).map((sub, i) => (
<View
key={sub.id}
className="border-card"
style={{
marginLeft: i === 0 ? 0 : -12,
borderRadius: 12,
borderWidth: 2,
}}
>
<SubscriptionIcon name={sub.name} size={36} />
</View>
Comment thread
coderabbitai[bot] marked this conversation as resolved.
))}
</View>
<View className="min-w-0 flex-1">
<Text className="text-base font-sans-semibold text-primary">
Your subscriptions
</Text>
<Text
numberOfLines={1}
className="mt-0.5 text-sm font-sans-medium text-muted-foreground"
>
{activeSubscriptions.length} active ·{" "}
{subscriptions.length} total
</Text>
</View>
</View>
<Text className="ml-2 text-2xl font-sans-medium text-muted-foreground">
</Text>
</View>
<Text className="text-2xl font-sans-medium text-muted-foreground">
</Text>
</Pressable>
</PressableScale>
)}
</ScrollView>

Expand Down
Loading