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
47 changes: 47 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Changelog

All notable changes to SubSync are documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Planned for v1.1.0
- Real OAuth integration for at least one provider (Gmail billing import is the highest-leverage target).
- Auto-update via `electron-updater` so portable users have a real upgrade path.
- macOS and Linux desktop builds.
- CSV / JSON export of subscriptions.
- Backup and restore of the local SQLite database.
- Notification worker wired up to the existing reminder preferences.

## [1.0.1] - TBD

See [`docs/release-notes-v1.0.1.md`](docs/release-notes-v1.0.1.md) for the full notes.

### Added
- Global `HttpExceptionFilter` so API errors return consistent JSON payloads instead of leaking stack traces.
- DTO validation on subscription, settings, and email-ingest endpoints.

### Fixed
- Dashboard monthly spend and spend-by-category now exclude `canceled_pending` subscriptions.
- `subscriptions.service.spec.ts` mocks the `ServiceCatalogService` dependency correctly and uses the `billingAmountCents` integer field.
- Cross-platform `dev:api` / `dev:web` scripts (previous `set VAR=...&&` form only worked on Windows).

### Changed
- Consolidated duplicated fetch helpers in the web client's `lib/api.ts`.

## [1.0.0] - 2026-05 (prior release)

See [`docs/release-notes-v1.0.0.md`](docs/release-notes-v1.0.0.md).

### Added
- Windows portable desktop executable bundling the NestJS API and Next.js web client.
- Local SQLite persistence for subscriptions, integrations, and settings.
- Dashboard summary metrics, renewal stack, and status-change feed.
- Manual subscription CRUD with `SubscriptionEvent` logging.
- Billing email import endpoint that creates or updates subscriptions.

### Known limitations
- Provider `Connect` actions persist local state only — no real third-party OAuth.
- The portable executable is unsigned, so Windows SmartScreen may warn on first launch.
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "api",
"version": "1.0.0",
"version": "1.0.1",
"description": "",
"author": "",
"private": true,
Expand Down
52 changes: 52 additions & 0 deletions apps/api/src/common/http-exception.filter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import {
ArgumentsHost,
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';

@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
private readonly logger = new Logger(HttpExceptionFilter.name);

catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const response = ctx.getResponse<Response>();

let status = HttpStatus.INTERNAL_SERVER_ERROR;
let message: string | string[] = 'Internal server error';
let error = 'Internal Server Error';

if (exception instanceof HttpException) {
status = exception.getStatus();
const exceptionResponse = exception.getResponse();
if (typeof exceptionResponse === 'string') {
message = exceptionResponse;
error = exceptionResponse;
} else if (
typeof exceptionResponse === 'object' &&
exceptionResponse !== null
) {
const resp = exceptionResponse as Record<string, unknown>;
if (resp.message !== undefined) {
message = resp.message as string | string[];
}
if (typeof resp.error === 'string') {
error = resp.error;
}
}
} else {
const err = exception instanceof Error ? exception : new Error(String(exception));
this.logger.error(err.message, err.stack);
}

response.status(status).json({
statusCode: status,
message,
error,
});
}
}
9 changes: 7 additions & 2 deletions apps/api/src/dashboard/dashboard.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ export class DashboardService {
const ordered = subscriptions
.slice()
.sort((a, b) => a.nextRenewal.localeCompare(b.nextRenewal));

const billableSubscriptions = subscriptions.filter(
(s) => s.status !== 'canceled_pending',
);

const sourceBreakdown: DashboardSummary['sourceBreakdown'] = {
manual: 0,
email: 0,
Expand All @@ -33,7 +38,7 @@ export class DashboardService {
}

const spendByCategoryMap = new Map<string, number>();
for (const subscription of subscriptions) {
for (const subscription of billableSubscriptions) {
const category =
servicesById[subscription.serviceId]?.category ?? 'other';
spendByCategoryMap.set(
Expand All @@ -53,7 +58,7 @@ export class DashboardService {

return {
monthlyEquivalentSpend: this.roundCurrency(
subscriptions.reduce(
billableSubscriptions.reduce(
(sum, subscription) => sum + this.toMonthlyEquivalent(subscription),
0,
),
Expand Down
1 change: 1 addition & 0 deletions apps/api/src/ingest/email-ingest.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export class EmailIngestPayload {

@IsOptional()
@IsString()
@MaxLength(50000)
body?: string;
}

Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { LoggingInterceptor } from './common/logging.interceptor';
import { HttpExceptionFilter } from './common/http-exception.filter';

async function bootstrap() {
const app = await NestFactory.create(AppModule);
Expand All @@ -12,6 +13,7 @@ async function bootstrap() {
transformOptions: { enableImplicitConversion: true },
}),
);
app.useGlobalFilters(new HttpExceptionFilter());
app.useGlobalInterceptors(new LoggingInterceptor());
app.setGlobalPrefix('api');

Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/settings/dto/update-settings.dto.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { IsArray, IsIn, IsInt, Min } from 'class-validator';
import { IsArray, IsIn, IsInt, Max, Min } from 'class-validator';
import { NotificationPreference } from '@subscription-tracker/types';

export class UpdateSettingsDto {
@IsInt()
@Min(0)
@Max(365)
leadTimeDays!: number;

@IsArray()
Expand Down
8 changes: 7 additions & 1 deletion apps/api/src/subscriptions/dto/create-subscription.dto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,25 @@ import {
IsISO8601,
Min,
IsIn,
MaxLength,
} from 'class-validator';
import { BillingInterval, Subscription } from '@subscription-tracker/types';

export class CreateSubscriptionDto {
@IsString()
@MaxLength(100)
serviceId!: string;

@IsString()
@MaxLength(150)
planName!: string;

@IsNumber({ maxDecimalPlaces: 2 })
@Min(0)
billingAmount!: number;

@IsString()
@MaxLength(3)
billingCurrency!: string;

@IsIn(['monthly', 'yearly', 'quarterly', 'custom'])
Expand All @@ -29,11 +33,12 @@ export class CreateSubscriptionDto {
nextRenewal!: string;

@IsOptional()
@IsString()
@IsIn(['card', 'paypal', 'gift', 'other'])
paymentSource?: 'card' | 'paypal' | 'gift' | 'other';

@IsOptional()
@IsString()
@MaxLength(4)
paymentLast4?: string;

@IsOptional()
Expand All @@ -42,5 +47,6 @@ export class CreateSubscriptionDto {

@IsOptional()
@IsString()
@MaxLength(1000)
notes?: string;
}
16 changes: 13 additions & 3 deletions apps/api/src/subscriptions/subscriptions.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NotFoundException } from '@nestjs/common';
import { Prisma } from '../../prisma/generated/client';
import { SubscriptionsService } from './subscriptions.service';
import { PrismaService } from '../prisma/prisma.service';
import { ServiceCatalogService } from '../service-catalog/service-catalog.service';

type PrismaMock = {
subscription: {
Expand All @@ -20,6 +20,7 @@ type PrismaMock = {
describe('SubscriptionsService', () => {
let service: SubscriptionsService;
let prisma: PrismaMock;
let serviceCatalog: jest.Mocked<Pick<ServiceCatalogService, 'ensureExists'>>;

beforeEach(() => {
prisma = {
Expand All @@ -36,15 +37,22 @@ describe('SubscriptionsService', () => {
},
};

service = new SubscriptionsService(prisma as unknown as PrismaService);
serviceCatalog = {
ensureExists: jest.fn().mockResolvedValue({ id: 'svc_spotify', name: 'Spotify' }),
};

service = new SubscriptionsService(
prisma as unknown as PrismaService,
serviceCatalog as unknown as ServiceCatalogService,
);
});

const subscriptionEntity = () => ({
id: 'sub_1',
serviceId: 'svc_spotify',
planName: 'Premium',
status: 'active',
billingAmount: new Prisma.Decimal(15),
billingAmountCents: 1500,
billingCurrency: 'USD',
billingInterval: 'monthly',
nextRenewal: new Date('2026-04-01T00:00:00.000Z'),
Expand Down Expand Up @@ -88,6 +96,7 @@ describe('SubscriptionsService', () => {
});
expect(result.id).toBe('sub_1');
expect(result.status).toBe('active');
expect(result.billingAmount).toBe(15);
});

it('records a status change event during update', async () => {
Expand Down Expand Up @@ -185,3 +194,4 @@ describe('SubscriptionsService', () => {
await expect(service.findOne('missing')).rejects.toThrow(NotFoundException);
});
});

2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "web",
"version": "1.0.0",
"version": "1.0.1",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
52 changes: 20 additions & 32 deletions apps/web/src/components/subscription-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import { ServiceProvider, Subscription } from '@subscription-tracker/types';
import { FormEvent, useState } from 'react';
import { useRouter } from 'next/navigation';
import { Button } from './ui/button';

const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ??
process.env.NEXT_PUBLIC_API_URL ??
'http://127.0.0.1:43100/api';
import {
createSubscription,
deleteSubscription,
updateSubscription,
} from '../lib/api';

interface Props {
services: ServiceProvider[];
Expand All @@ -30,31 +30,23 @@ export function SubscriptionForm({ services, mode, initial }: Props) {

const formData = new FormData(event.currentTarget);
const payload = {
serviceId: formData.get('serviceId'),
planName: formData.get('planName'),
serviceId: formData.get('serviceId') as string,
planName: formData.get('planName') as string,
billingAmount: Number(formData.get('billingAmount')),
billingCurrency: formData.get('billingCurrency'),
billingInterval: formData.get('billingInterval'),
nextRenewal: formData.get('nextRenewal'),
paymentSource: formData.get('paymentSource') || undefined,
paymentLast4: formData.get('paymentLast4') || undefined,
notes: formData.get('notes') || undefined,
status: formData.get('status') || undefined,
billingCurrency: formData.get('billingCurrency') as string,
billingInterval: formData.get('billingInterval') as Subscription['billingInterval'],
nextRenewal: `${formData.get('nextRenewal') as string}T00:00:00.000Z`,
paymentSource: (formData.get('paymentSource') as Subscription['paymentSource']) || undefined,
paymentLast4: (formData.get('paymentLast4') as string) || undefined,
notes: (formData.get('notes') as string) || undefined,
status: (formData.get('status') as Subscription['status']) || undefined,
};

const url =
mode === 'edit' && initial
? `${API_BASE}/subscriptions/${initial.id}`
: `${API_BASE}/subscriptions`;

try {
const response = await fetch(url, {
method: mode === 'edit' ? 'PATCH' : 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(await response.text());
if (mode === 'edit' && initial) {
await updateSubscription(initial.id, payload);
} else {
await createSubscription(payload);
}
setStatus('success');
router.push('/dashboard');
Expand All @@ -70,12 +62,7 @@ export function SubscriptionForm({ services, mode, initial }: Props) {
if (!confirm('Delete this subscription?')) return;

try {
const response = await fetch(`${API_BASE}/subscriptions/${initial.id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(await response.text());
}
await deleteSubscription(initial.id);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid parsing the empty delete response

When deleting from the edit form, this now routes through deleteSubscription, whose shared apiRequest only skips response.json() for HTTP 204 responses. The API SubscriptionsController.delete returns Promise<void> without @HttpCode(204), so Nest's default DELETE response is a successful empty 200; after the subscription is removed, the helper tries to parse the empty body and rejects, leaving the user on the form with an error instead of navigating back.

Useful? React with 👍 / 👎.

router.push('/dashboard');
router.refresh();
} catch (err) {
Expand Down Expand Up @@ -133,6 +120,7 @@ export function SubscriptionForm({ services, mode, initial }: Props) {
name="billingCurrency"
defaultValue={initial?.billingCurrency ?? 'USD'}
required
maxLength={3}
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
/>
</div>
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,16 @@ export function createSubscription(payload: CreateSubscriptionPayload) {
});
}

export function updateSubscription(
id: string,
payload: Partial<CreateSubscriptionPayload>,
) {
return apiRequest<Subscription>(`/subscriptions/${id}`, {
method: 'PATCH',
body: JSON.stringify(payload),
});
}

export function deleteSubscription(id: string) {
return apiRequest<void>(`/subscriptions/${id}`, {
method: 'DELETE',
Expand Down
5 changes: 3 additions & 2 deletions docs/release-checklist.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@
- Run `npm run test:e2e --workspace api`
- Run `npm run build:desktop`
- Run `npm run dist:desktop`
- Smoke-test the generated `release/SubSync 1.0.0.exe`
- Smoke-test the generated `release/SubSync ${VERSION}.exe`
- Update `CHANGELOG.md` with the new version's entry and move planned items out of `[Unreleased]`

## Release contents
- Upload `release/SubSync 1.0.0.exe` to GitHub Releases
- Upload `release/SubSync ${VERSION}.exe` to GitHub Releases
- Include release notes that mention:
- local SQLite storage
- dashboard summary metrics
Expand Down
Loading