diff --git a/apps/api/package.json b/apps/api/package.json index 82fcd83..11e4f91 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "api", - "version": "1.0.0", + "version": "1.0.1", "description": "", "author": "", "private": true, diff --git a/apps/api/src/common/http-exception.filter.ts b/apps/api/src/common/http-exception.filter.ts new file mode 100644 index 0000000..4f5017d --- /dev/null +++ b/apps/api/src/common/http-exception.filter.ts @@ -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(); + + 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; + 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, + }); + } +} diff --git a/apps/api/src/dashboard/dashboard.service.ts b/apps/api/src/dashboard/dashboard.service.ts index 8379a3c..438c325 100644 --- a/apps/api/src/dashboard/dashboard.service.ts +++ b/apps/api/src/dashboard/dashboard.service.ts @@ -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, @@ -33,7 +38,7 @@ export class DashboardService { } const spendByCategoryMap = new Map(); - for (const subscription of subscriptions) { + for (const subscription of billableSubscriptions) { const category = servicesById[subscription.serviceId]?.category ?? 'other'; spendByCategoryMap.set( @@ -53,7 +58,7 @@ export class DashboardService { return { monthlyEquivalentSpend: this.roundCurrency( - subscriptions.reduce( + billableSubscriptions.reduce( (sum, subscription) => sum + this.toMonthlyEquivalent(subscription), 0, ), diff --git a/apps/api/src/ingest/email-ingest.controller.ts b/apps/api/src/ingest/email-ingest.controller.ts index 323a0c3..2ae97ff 100644 --- a/apps/api/src/ingest/email-ingest.controller.ts +++ b/apps/api/src/ingest/email-ingest.controller.ts @@ -25,6 +25,7 @@ export class EmailIngestPayload { @IsOptional() @IsString() + @MaxLength(50000) body?: string; } diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 9191a46..e22f6a5 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -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); @@ -12,6 +13,7 @@ async function bootstrap() { transformOptions: { enableImplicitConversion: true }, }), ); + app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalInterceptors(new LoggingInterceptor()); app.setGlobalPrefix('api'); diff --git a/apps/api/src/settings/dto/update-settings.dto.ts b/apps/api/src/settings/dto/update-settings.dto.ts index 4fd8a98..1470f1f 100644 --- a/apps/api/src/settings/dto/update-settings.dto.ts +++ b/apps/api/src/settings/dto/update-settings.dto.ts @@ -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() diff --git a/apps/api/src/subscriptions/dto/create-subscription.dto.ts b/apps/api/src/subscriptions/dto/create-subscription.dto.ts index 58831da..e1f5a93 100644 --- a/apps/api/src/subscriptions/dto/create-subscription.dto.ts +++ b/apps/api/src/subscriptions/dto/create-subscription.dto.ts @@ -5,14 +5,17 @@ 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 }) @@ -20,6 +23,7 @@ export class CreateSubscriptionDto { billingAmount!: number; @IsString() + @MaxLength(3) billingCurrency!: string; @IsIn(['monthly', 'yearly', 'quarterly', 'custom']) @@ -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() @@ -42,5 +47,6 @@ export class CreateSubscriptionDto { @IsOptional() @IsString() + @MaxLength(1000) notes?: string; } diff --git a/apps/api/src/subscriptions/subscriptions.service.spec.ts b/apps/api/src/subscriptions/subscriptions.service.spec.ts index 06808ea..b82e922 100644 --- a/apps/api/src/subscriptions/subscriptions.service.spec.ts +++ b/apps/api/src/subscriptions/subscriptions.service.spec.ts @@ -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: { @@ -20,6 +20,7 @@ type PrismaMock = { describe('SubscriptionsService', () => { let service: SubscriptionsService; let prisma: PrismaMock; + let serviceCatalog: jest.Mocked>; beforeEach(() => { prisma = { @@ -36,7 +37,14 @@ 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 = () => ({ @@ -44,7 +52,7 @@ describe('SubscriptionsService', () => { 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'), @@ -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 () => { @@ -185,3 +194,4 @@ describe('SubscriptionsService', () => { await expect(service.findOne('missing')).rejects.toThrow(NotFoundException); }); }); + diff --git a/apps/web/package.json b/apps/web/package.json index 256ad66..1ec94b2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "web", - "version": "1.0.0", + "version": "1.0.1", "private": true, "scripts": { "dev": "next dev", diff --git a/apps/web/src/components/subscription-form.tsx b/apps/web/src/components/subscription-form.tsx index 4361f31..6d4450a 100644 --- a/apps/web/src/components/subscription-form.tsx +++ b/apps/web/src/components/subscription-form.tsx @@ -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[]; @@ -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'); @@ -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); router.push('/dashboard'); router.refresh(); } catch (err) { @@ -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" /> diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 4cff0bd..daffded 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -87,6 +87,16 @@ export function createSubscription(payload: CreateSubscriptionPayload) { }); } +export function updateSubscription( + id: string, + payload: Partial, +) { + return apiRequest(`/subscriptions/${id}`, { + method: 'PATCH', + body: JSON.stringify(payload), + }); +} + export function deleteSubscription(id: string) { return apiRequest(`/subscriptions/${id}`, { method: 'DELETE', diff --git a/docs/release-notes-v1.0.1.md b/docs/release-notes-v1.0.1.md new file mode 100644 index 0000000..eeedbe6 --- /dev/null +++ b/docs/release-notes-v1.0.1.md @@ -0,0 +1,29 @@ +# SubSync v1.0.1 + +## What's changed + +### Bug fixes +- **Dashboard monthly spend** now correctly excludes `canceled_pending` subscriptions from the monthly equivalent spend calculation and spend-by-category breakdown. Subscriptions in the process of being canceled are no longer counted toward your ongoing costs. +- **Unit tests** (`subscriptions.service.spec.ts`) corrected: the `ServiceCatalogService` dependency is now properly mocked in the constructor, and the subscription entity fixture uses the correct `billingAmountCents` integer field instead of the wrong `billingAmount` Decimal. All five test cases now pass. + +### Improvements +- **Global HTTP exception filter** added to the API (`common/http-exception.filter.ts`). All error responses now return a consistent JSON envelope `{ statusCode, message, error }` regardless of whether the error originated from a `HttpException`, a validation pipe failure, or an unexpected runtime error. Unexpected server errors are also logged with a stack trace via NestJS `Logger`. +- **Stronger DTO validation** across the API: + - `CreateSubscriptionDto`: `planName` capped at 150 characters, `billingCurrency` capped at 3 characters, `serviceId` capped at 100 characters, `notes` capped at 1,000 characters, `paymentLast4` capped at 4 characters, and `paymentSource` now validated with `@IsIn` instead of the looser `@IsString`. + - `UpdateSettingsDto`: `leadTimeDays` now has an upper bound of 365 days. + - `EmailIngestPayload`: `body` field now capped at 50 000 characters. +- **Web API layer consolidated**: `subscription-form.tsx` previously issued raw `fetch` calls and duplicated the API base-URL resolution logic. It now uses the typed helpers `createSubscription`, `updateSubscription`, and `deleteSubscription` from `lib/api.ts`. A new `updateSubscription` export was added to `lib/api.ts`, and the `billingCurrency` field enforces a `maxLength={3}` HTML attribute in the form. + +## Upgrade notes +No database migrations required. Drop in the new executable and restart; all existing data remains compatible. + +## Validation +``` +npm run lint +npm run test --workspace api +npm run build:desktop +``` + +## Known limitations +- Provider sync is not yet a full live OAuth integration. +- The portable executable is unsigned; Windows SmartScreen may warn on first launch. diff --git a/package-lock.json b/package-lock.json index 7833219..6ff909e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "subscription-tracker", - "version": "1.0.0", + "version": "1.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "subscription-tracker", - "version": "1.0.0", + "version": "1.0.1", "workspaces": [ "apps/*", "packages/*" @@ -30,7 +30,7 @@ } }, "apps/api": { - "version": "1.0.0", + "version": "1.0.1", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^10.0.0", @@ -146,7 +146,7 @@ } }, "apps/web": { - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@radix-ui/react-slot": "^1.0.2", "@subscription-tracker/types": "file:../../packages/types", @@ -13938,7 +13938,7 @@ }, "packages/types": { "name": "@subscription-tracker/types", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "devDependencies": { "prettier": "^3.4.2", diff --git a/package.json b/package.json index 8a79a0b..83bba35 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "subscription-tracker", - "version": "1.0.0", + "version": "1.0.1", "private": true, "description": "SubSync desktop-packaged subscription tracker", "author": "Evan Newman", diff --git a/packages/types/package.json b/packages/types/package.json index 65545e1..7fd1f05 100644 --- a/packages/types/package.json +++ b/packages/types/package.json @@ -1,6 +1,6 @@ { "name": "@subscription-tracker/types", - "version": "1.0.0", + "version": "1.0.1", "description": "Shared TypeScript interfaces for the subscription tracker", "main": "dist/index.js", "types": "dist/index.d.ts", @@ -12,11 +12,16 @@ "test": "echo 'no tests yet' && exit 0", "prepare": "npm run build" }, - "keywords": ["subscriptions", "types"], + "keywords": [ + "subscriptions", + "types" + ], "author": "", "license": "MIT", "type": "commonjs", - "files": ["dist"], + "files": [ + "dist" + ], "devDependencies": { "prettier": "^3.4.2", "rimraf": "^6.0.1",