Skip to content
Draft
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
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);
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
29 changes: 29 additions & 0 deletions docs/release-notes-v1.0.1.md
Original file line number Diff line number Diff line change
@@ -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.
Loading