Merge pull request 'refactor: Link ownership-costs to documents feature (#29)' (#30) from issue-29-link-ownership-costs into main
All checks were successful
Deploy to Staging / Build Images (push) Successful in 25s
Deploy to Staging / Deploy to Staging (push) Successful in 28s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
All checks were successful
Deploy to Staging / Build Images (push) Successful in 25s
Deploy to Staging / Deploy to Staging (push) Successful in 28s
Deploy to Staging / Verify Staging (push) Successful in 7s
Deploy to Staging / Notify Staging Ready (push) Successful in 6s
Deploy to Staging / Notify Staging Failure (push) Has been skipped
Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
@@ -1,15 +1,18 @@
|
|||||||
import { randomUUID } from 'crypto';
|
import { randomUUID } from 'crypto';
|
||||||
import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types';
|
import type { CreateDocumentBody, DocumentRecord, DocumentType, UpdateDocumentBody } from './documents.types';
|
||||||
import { DocumentsRepository } from '../data/documents.repository';
|
import { DocumentsRepository } from '../data/documents.repository';
|
||||||
|
import { OwnershipCostsService } from '../../ownership-costs/domain/ownership-costs.service';
|
||||||
|
import type { OwnershipCostType } from '../../ownership-costs/domain/ownership-costs.types';
|
||||||
import pool from '../../../core/config/database';
|
import pool from '../../../core/config/database';
|
||||||
|
|
||||||
export class DocumentsService {
|
export class DocumentsService {
|
||||||
private readonly repo = new DocumentsRepository(pool);
|
private readonly repo = new DocumentsRepository(pool);
|
||||||
|
private readonly ownershipCostsService = new OwnershipCostsService(pool);
|
||||||
|
|
||||||
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
async createDocument(userId: string, body: CreateDocumentBody): Promise<DocumentRecord> {
|
||||||
await this.assertVehicleOwnership(userId, body.vehicleId);
|
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
return this.repo.insert({
|
const doc = await this.repo.insert({
|
||||||
id,
|
id,
|
||||||
userId,
|
userId,
|
||||||
vehicleId: body.vehicleId,
|
vehicleId: body.vehicleId,
|
||||||
@@ -22,6 +25,70 @@ export class DocumentsService {
|
|||||||
emailNotifications: body.emailNotifications ?? false,
|
emailNotifications: body.emailNotifications ?? false,
|
||||||
scanForMaintenance: body.scanForMaintenance ?? false,
|
scanForMaintenance: body.scanForMaintenance ?? false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-create ownership_cost when insurance/registration has cost data
|
||||||
|
await this.autoCreateOwnershipCost(userId, doc, body);
|
||||||
|
|
||||||
|
return doc;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-creates an ownership_cost record when an insurance or registration
|
||||||
|
* document is created with cost data (premium or cost field in details).
|
||||||
|
*/
|
||||||
|
private async autoCreateOwnershipCost(
|
||||||
|
userId: string,
|
||||||
|
doc: DocumentRecord,
|
||||||
|
body: CreateDocumentBody
|
||||||
|
): Promise<void> {
|
||||||
|
const costType = this.mapDocumentTypeToCostType(body.documentType);
|
||||||
|
if (!costType) return; // Not a cost-linkable document type
|
||||||
|
|
||||||
|
const costAmount = this.extractCostAmount(body);
|
||||||
|
if (!costAmount || costAmount <= 0) return; // No valid cost data
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.ownershipCostsService.createCost(userId, {
|
||||||
|
vehicleId: body.vehicleId,
|
||||||
|
documentId: doc.id,
|
||||||
|
costType,
|
||||||
|
amount: costAmount,
|
||||||
|
description: doc.title,
|
||||||
|
periodStart: body.issuedDate,
|
||||||
|
periodEnd: body.expirationDate,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// Log but don't fail document creation if cost creation fails
|
||||||
|
console.error('Failed to auto-create ownership cost for document:', doc.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps document types to ownership cost types.
|
||||||
|
* Returns null for document types that don't auto-create costs.
|
||||||
|
*/
|
||||||
|
private mapDocumentTypeToCostType(documentType: string): OwnershipCostType | null {
|
||||||
|
const typeMap: Record<string, OwnershipCostType> = {
|
||||||
|
'insurance': 'insurance',
|
||||||
|
'registration': 'registration',
|
||||||
|
};
|
||||||
|
return typeMap[documentType] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts cost amount from document details.
|
||||||
|
* Insurance uses 'premium', registration uses 'cost'.
|
||||||
|
*/
|
||||||
|
private extractCostAmount(body: CreateDocumentBody): number | null {
|
||||||
|
if (!body.details) return null;
|
||||||
|
|
||||||
|
const premium = body.details.premium;
|
||||||
|
const cost = body.details.cost;
|
||||||
|
|
||||||
|
if (typeof premium === 'number' && premium > 0) return premium;
|
||||||
|
if (typeof cost === 'number' && cost > 0) return cost;
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDocument(userId: string, id: string): Promise<DocumentRecord | null> {
|
async getDocument(userId: string, id: string): Promise<DocumentRecord | null> {
|
||||||
@@ -36,12 +103,78 @@ export class DocumentsService {
|
|||||||
const existing = await this.repo.findById(id, userId);
|
const existing = await this.repo.findById(id, userId);
|
||||||
if (!existing) return null;
|
if (!existing) return null;
|
||||||
if (patch && typeof patch === 'object') {
|
if (patch && typeof patch === 'object') {
|
||||||
return this.repo.updateMetadata(id, userId, patch as any);
|
const updated = await this.repo.updateMetadata(id, userId, patch as any);
|
||||||
|
|
||||||
|
// Sync cost changes to linked ownership_cost if applicable
|
||||||
|
if (updated && patch.details) {
|
||||||
|
await this.syncOwnershipCost(userId, updated, patch);
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
}
|
}
|
||||||
return existing;
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs cost data changes to linked ownership_cost record.
|
||||||
|
* If document has linked cost and details.premium/cost changed, update it.
|
||||||
|
*/
|
||||||
|
private async syncOwnershipCost(
|
||||||
|
userId: string,
|
||||||
|
doc: DocumentRecord,
|
||||||
|
patch: UpdateDocumentBody
|
||||||
|
): Promise<void> {
|
||||||
|
const costType = this.mapDocumentTypeToCostType(doc.documentType);
|
||||||
|
if (!costType) return;
|
||||||
|
|
||||||
|
const newCostAmount = this.extractCostAmountFromDetails(patch.details);
|
||||||
|
if (newCostAmount === null) return; // No cost in update
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Find existing linked cost
|
||||||
|
const linkedCosts = await this.ownershipCostsService.getCosts(userId, { documentId: doc.id });
|
||||||
|
|
||||||
|
if (linkedCosts.length > 0 && newCostAmount > 0) {
|
||||||
|
// Update existing linked cost
|
||||||
|
await this.ownershipCostsService.updateCost(userId, linkedCosts[0].id, {
|
||||||
|
amount: newCostAmount,
|
||||||
|
periodStart: patch.issuedDate ?? undefined,
|
||||||
|
periodEnd: patch.expirationDate ?? undefined,
|
||||||
|
});
|
||||||
|
} else if (linkedCosts.length === 0 && newCostAmount > 0) {
|
||||||
|
// Create new cost if none exists
|
||||||
|
await this.ownershipCostsService.createCost(userId, {
|
||||||
|
vehicleId: doc.vehicleId,
|
||||||
|
documentId: doc.id,
|
||||||
|
costType,
|
||||||
|
amount: newCostAmount,
|
||||||
|
description: doc.title,
|
||||||
|
periodStart: patch.issuedDate ?? doc.issuedDate ?? undefined,
|
||||||
|
periodEnd: patch.expirationDate ?? doc.expirationDate ?? undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to sync ownership cost for document:', doc.id, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts cost amount from details object (for updates).
|
||||||
|
*/
|
||||||
|
private extractCostAmountFromDetails(details?: Record<string, any> | null): number | null {
|
||||||
|
if (!details) return null;
|
||||||
|
|
||||||
|
const premium = details.premium;
|
||||||
|
const cost = details.cost;
|
||||||
|
|
||||||
|
if (typeof premium === 'number') return premium;
|
||||||
|
if (typeof cost === 'number') return cost;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
async deleteDocument(userId: string, id: string): Promise<void> {
|
async deleteDocument(userId: string, id: string): Promise<void> {
|
||||||
|
// Note: Linked ownership_cost records are CASCADE deleted via FK
|
||||||
await this.repo.softDelete(id, userId);
|
await this.repo.softDelete(id, userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
153
backend/src/features/ownership-costs/README.md
Normal file
153
backend/src/features/ownership-costs/README.md
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
# Ownership Costs Feature
|
||||||
|
|
||||||
|
Tracks vehicle ownership costs including insurance, registration, tax, inspection, parking, and other costs.
|
||||||
|
|
||||||
|
## Database Schema
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ownership_costs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
user_id VARCHAR(255) NOT NULL,
|
||||||
|
vehicle_id UUID NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE,
|
||||||
|
document_id UUID NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
|
cost_type VARCHAR(32) NOT NULL CHECK (cost_type IN ('insurance', 'registration', 'tax', 'inspection', 'parking', 'other')),
|
||||||
|
amount DECIMAL(10, 2) NOT NULL CHECK (amount > 0),
|
||||||
|
description VARCHAR(200),
|
||||||
|
period_start DATE,
|
||||||
|
period_end DATE,
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### POST /ownership-costs
|
||||||
|
Create a new ownership cost record.
|
||||||
|
|
||||||
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
|
**Request Body**:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vehicleId": "uuid",
|
||||||
|
"documentId": "uuid (optional)",
|
||||||
|
"costType": "insurance | registration | tax | inspection | parking | other",
|
||||||
|
"amount": 150.00,
|
||||||
|
"description": "Auto insurance premium (optional)",
|
||||||
|
"periodStart": "2024-01-01 (optional)",
|
||||||
|
"periodEnd": "2024-12-31 (optional)",
|
||||||
|
"notes": "Additional notes (optional)"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: 201 Created
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"userId": "string",
|
||||||
|
"vehicleId": "uuid",
|
||||||
|
"documentId": "uuid",
|
||||||
|
"costType": "insurance",
|
||||||
|
"amount": 150.00,
|
||||||
|
"description": "Auto insurance premium",
|
||||||
|
"periodStart": "2024-01-01",
|
||||||
|
"periodEnd": "2024-12-31",
|
||||||
|
"notes": "Additional notes",
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /ownership-costs
|
||||||
|
List all ownership costs for the authenticated user.
|
||||||
|
|
||||||
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
|
**Query Parameters**:
|
||||||
|
- `vehicleId` (optional): Filter by vehicle UUID
|
||||||
|
- `costType` (optional): Filter by cost type
|
||||||
|
- `documentId` (optional): Filter by document UUID
|
||||||
|
|
||||||
|
**Response**: 200 OK
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "uuid",
|
||||||
|
"userId": "string",
|
||||||
|
"vehicleId": "uuid",
|
||||||
|
"costType": "insurance",
|
||||||
|
"amount": 150.00,
|
||||||
|
"description": "Auto insurance premium",
|
||||||
|
"periodStart": "2024-01-01",
|
||||||
|
"periodEnd": "2024-12-31",
|
||||||
|
"createdAt": "2024-01-01T00:00:00.000Z",
|
||||||
|
"updatedAt": "2024-01-01T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### GET /ownership-costs/:id
|
||||||
|
Get a specific ownership cost record.
|
||||||
|
|
||||||
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
|
**Response**: 200 OK (same as POST response)
|
||||||
|
|
||||||
|
**Errors**:
|
||||||
|
- 404 Not Found: Cost not found or not owned by user
|
||||||
|
|
||||||
|
### PUT /ownership-costs/:id
|
||||||
|
Update an ownership cost record.
|
||||||
|
|
||||||
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
|
**Request Body**: (all fields optional)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documentId": "uuid",
|
||||||
|
"costType": "insurance",
|
||||||
|
"amount": 160.00,
|
||||||
|
"description": "Updated description",
|
||||||
|
"periodStart": "2024-01-01",
|
||||||
|
"periodEnd": "2024-12-31",
|
||||||
|
"notes": "Updated notes"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response**: 200 OK (same as POST response)
|
||||||
|
|
||||||
|
**Errors**:
|
||||||
|
- 404 Not Found: Cost not found or not owned by user
|
||||||
|
|
||||||
|
### DELETE /ownership-costs/:id
|
||||||
|
Delete an ownership cost record.
|
||||||
|
|
||||||
|
**Auth**: Required (JWT)
|
||||||
|
|
||||||
|
**Response**: 204 No Content
|
||||||
|
|
||||||
|
**Errors**:
|
||||||
|
- 404 Not Found: Cost not found or not owned by user
|
||||||
|
|
||||||
|
## Cost Types
|
||||||
|
|
||||||
|
| Type | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| insurance | Auto insurance premiums |
|
||||||
|
| registration | Vehicle registration fees |
|
||||||
|
| tax | Property or excise taxes |
|
||||||
|
| inspection | State inspection fees |
|
||||||
|
| parking | Parking permits, monthly fees |
|
||||||
|
| other | Other ownership costs |
|
||||||
|
|
||||||
|
## Authorization
|
||||||
|
|
||||||
|
All endpoints enforce user scoping - users can only access their own ownership cost records. Vehicle ownership is verified before creating records.
|
||||||
|
|
||||||
|
## Integration with Other Features
|
||||||
|
|
||||||
|
- **Documents**: Optional document_id links costs to supporting documents
|
||||||
|
- **Vehicles**: vehicle_id foreign key with CASCADE delete
|
||||||
|
- **TCO Calculation**: Service provides `getVehicleOwnershipCosts()` for total cost of ownership aggregation
|
||||||
@@ -1,225 +1,145 @@
|
|||||||
/**
|
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||||
* @ai-summary Fastify route handlers for ownership costs API
|
|
||||||
* @ai-context HTTP request/response handling with Fastify reply methods
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { FastifyRequest, FastifyReply } from 'fastify';
|
|
||||||
import { OwnershipCostsService } from '../domain/ownership-costs.service';
|
import { OwnershipCostsService } from '../domain/ownership-costs.service';
|
||||||
import { pool } from '../../../core/config/database';
|
import type { CreateBody, UpdateBody, IdParams, ListQuery } from './ownership-costs.validation';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { logger } from '../../../core/logging/logger';
|
||||||
import {
|
|
||||||
OwnershipCostParams,
|
|
||||||
VehicleParams,
|
|
||||||
CreateOwnershipCostBody,
|
|
||||||
UpdateOwnershipCostBody
|
|
||||||
} from '../domain/ownership-costs.types';
|
|
||||||
|
|
||||||
export class OwnershipCostsController {
|
export class OwnershipCostsController {
|
||||||
private service: OwnershipCostsService;
|
private readonly service = new OwnershipCostsService();
|
||||||
|
|
||||||
constructor() {
|
async list(request: FastifyRequest<{ Querystring: ListQuery }>, reply: FastifyReply) {
|
||||||
this.service = new OwnershipCostsService(pool);
|
const userId = (request as any).user?.sub as string;
|
||||||
|
|
||||||
|
logger.info('Ownership costs list requested', {
|
||||||
|
operation: 'ownership-costs.list',
|
||||||
|
userId,
|
||||||
|
filters: {
|
||||||
|
vehicleId: request.query.vehicleId,
|
||||||
|
costType: request.query.costType,
|
||||||
|
documentId: request.query.documentId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const costs = await this.service.getCosts(userId, {
|
||||||
|
vehicleId: request.query.vehicleId,
|
||||||
|
costType: request.query.costType,
|
||||||
|
documentId: request.query.documentId,
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.info('Ownership costs list retrieved', {
|
||||||
|
operation: 'ownership-costs.list.success',
|
||||||
|
userId,
|
||||||
|
costCount: costs.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(costs);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(request: FastifyRequest<{ Body: CreateOwnershipCostBody }>, reply: FastifyReply) {
|
async get(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
try {
|
const userId = (request as any).user?.sub as string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const costId = request.params.id;
|
||||||
const userId = (request as any).user.sub;
|
|
||||||
const cost = await this.service.create(request.body, userId);
|
|
||||||
|
|
||||||
return reply.code(201).send(cost);
|
logger.info('Ownership cost get requested', {
|
||||||
} catch (error: unknown) {
|
operation: 'ownership-costs.get',
|
||||||
const err = error as Error;
|
userId,
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
costId,
|
||||||
logger.error('Error creating ownership cost', { error: err, userId: (request as any).user?.sub });
|
});
|
||||||
|
|
||||||
if (err.message.includes('not found')) {
|
const cost = await this.service.getCost(userId, costId);
|
||||||
return reply.code(404).send({
|
if (!cost) {
|
||||||
error: 'Not Found',
|
logger.warn('Ownership cost not found', {
|
||||||
message: err.message
|
operation: 'ownership-costs.get.not_found',
|
||||||
});
|
userId,
|
||||||
}
|
costId,
|
||||||
if (err.message.includes('Unauthorized')) {
|
|
||||||
return reply.code(403).send({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(500).send({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: 'Failed to create ownership cost'
|
|
||||||
});
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Ownership cost retrieved', {
|
||||||
|
operation: 'ownership-costs.get.success',
|
||||||
|
userId,
|
||||||
|
costId,
|
||||||
|
vehicleId: cost.vehicleId,
|
||||||
|
costType: cost.costType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(cost);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByVehicle(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
async create(request: FastifyRequest<{ Body: CreateBody }>, reply: FastifyReply) {
|
||||||
try {
|
const userId = (request as any).user?.sub as string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const userId = (request as any).user.sub;
|
|
||||||
const { vehicleId } = request.params;
|
|
||||||
|
|
||||||
const costs = await this.service.getByVehicleId(vehicleId, userId);
|
logger.info('Ownership cost create requested', {
|
||||||
|
operation: 'ownership-costs.create',
|
||||||
|
userId,
|
||||||
|
vehicleId: request.body.vehicleId,
|
||||||
|
costType: request.body.costType,
|
||||||
|
amount: request.body.amount,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(costs);
|
const created = await this.service.createCost(userId, request.body);
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
logger.error('Error listing ownership costs', { error: err, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
|
||||||
|
|
||||||
if (err.message.includes('not found')) {
|
logger.info('Ownership cost created', {
|
||||||
return reply.code(404).send({
|
operation: 'ownership-costs.create.success',
|
||||||
error: 'Not Found',
|
userId,
|
||||||
message: err.message
|
costId: created.id,
|
||||||
});
|
vehicleId: created.vehicleId,
|
||||||
}
|
costType: created.costType,
|
||||||
if (err.message.includes('Unauthorized')) {
|
amount: created.amount,
|
||||||
return reply.code(403).send({
|
});
|
||||||
error: 'Forbidden',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(201).send(created);
|
||||||
error: 'Internal server error',
|
|
||||||
message: 'Failed to get ownership costs'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(request: FastifyRequest<{ Params: OwnershipCostParams }>, reply: FastifyReply) {
|
async update(request: FastifyRequest<{ Params: IdParams; Body: UpdateBody }>, reply: FastifyReply) {
|
||||||
try {
|
const userId = (request as any).user?.sub as string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const costId = request.params.id;
|
||||||
const userId = (request as any).user.sub;
|
|
||||||
const { id } = request.params;
|
|
||||||
|
|
||||||
const cost = await this.service.getById(id, userId);
|
logger.info('Ownership cost update requested', {
|
||||||
|
operation: 'ownership-costs.update',
|
||||||
|
userId,
|
||||||
|
costId,
|
||||||
|
updateFields: Object.keys(request.body),
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(cost);
|
const updated = await this.service.updateCost(userId, costId, request.body);
|
||||||
} catch (error: unknown) {
|
if (!updated) {
|
||||||
const err = error as Error;
|
logger.warn('Ownership cost not found for update', {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
operation: 'ownership-costs.update.not_found',
|
||||||
logger.error('Error getting ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
|
userId,
|
||||||
|
costId,
|
||||||
if (err.message.includes('not found')) {
|
|
||||||
return reply.code(404).send({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (err.message.includes('Unauthorized')) {
|
|
||||||
return reply.code(403).send({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(500).send({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: 'Failed to get ownership cost'
|
|
||||||
});
|
});
|
||||||
|
return reply.code(404).send({ error: 'Not Found' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info('Ownership cost updated', {
|
||||||
|
operation: 'ownership-costs.update.success',
|
||||||
|
userId,
|
||||||
|
costId,
|
||||||
|
vehicleId: updated.vehicleId,
|
||||||
|
costType: updated.costType,
|
||||||
|
});
|
||||||
|
|
||||||
|
return reply.code(200).send(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(request: FastifyRequest<{ Params: OwnershipCostParams; Body: UpdateOwnershipCostBody }>, reply: FastifyReply) {
|
async remove(request: FastifyRequest<{ Params: IdParams }>, reply: FastifyReply) {
|
||||||
try {
|
const userId = (request as any).user?.sub as string;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
const costId = request.params.id;
|
||||||
const userId = (request as any).user.sub;
|
|
||||||
const { id } = request.params;
|
|
||||||
|
|
||||||
const updatedCost = await this.service.update(id, request.body, userId);
|
logger.info('Ownership cost delete requested', {
|
||||||
|
operation: 'ownership-costs.delete',
|
||||||
|
userId,
|
||||||
|
costId,
|
||||||
|
});
|
||||||
|
|
||||||
return reply.code(200).send(updatedCost);
|
await this.service.deleteCost(userId, costId);
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
logger.error('Error updating ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
|
|
||||||
|
|
||||||
if (err.message.includes('not found')) {
|
logger.info('Ownership cost deleted', {
|
||||||
return reply.code(404).send({
|
operation: 'ownership-costs.delete.success',
|
||||||
error: 'Not Found',
|
userId,
|
||||||
message: err.message
|
costId,
|
||||||
});
|
});
|
||||||
}
|
|
||||||
if (err.message.includes('Unauthorized')) {
|
|
||||||
return reply.code(403).send({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(500).send({
|
return reply.code(204).send();
|
||||||
error: 'Internal server error',
|
|
||||||
message: 'Failed to update ownership cost'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(request: FastifyRequest<{ Params: OwnershipCostParams }>, reply: FastifyReply) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const userId = (request as any).user.sub;
|
|
||||||
const { id } = request.params;
|
|
||||||
|
|
||||||
await this.service.delete(id, userId);
|
|
||||||
|
|
||||||
return reply.code(204).send();
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
logger.error('Error deleting ownership cost', { error: err, costId: request.params.id, userId: (request as any).user?.sub });
|
|
||||||
|
|
||||||
if (err.message.includes('not found')) {
|
|
||||||
return reply.code(404).send({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (err.message.includes('Unauthorized')) {
|
|
||||||
return reply.code(403).send({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(500).send({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: 'Failed to delete ownership cost'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async getVehicleStats(request: FastifyRequest<{ Params: VehicleParams }>, reply: FastifyReply) {
|
|
||||||
try {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
const userId = (request as any).user.sub;
|
|
||||||
const { vehicleId } = request.params;
|
|
||||||
|
|
||||||
const stats = await this.service.getVehicleCostStats(vehicleId, userId);
|
|
||||||
|
|
||||||
return reply.code(200).send(stats);
|
|
||||||
} catch (error: unknown) {
|
|
||||||
const err = error as Error;
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
logger.error('Error getting ownership cost stats', { error: err, vehicleId: request.params.vehicleId, userId: (request as any).user?.sub });
|
|
||||||
|
|
||||||
if (err.message.includes('not found')) {
|
|
||||||
return reply.code(404).send({
|
|
||||||
error: 'Not Found',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (err.message.includes('Unauthorized')) {
|
|
||||||
return reply.code(403).send({
|
|
||||||
error: 'Forbidden',
|
|
||||||
message: err.message
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return reply.code(500).send({
|
|
||||||
error: 'Internal server error',
|
|
||||||
message: 'Failed to get ownership cost stats'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,38 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Fastify routes for ownership costs API
|
* @ai-summary Fastify routes for ownership costs API
|
||||||
* @ai-context Route definitions with Fastify plugin pattern and authentication
|
|
||||||
*/
|
*/
|
||||||
|
import { FastifyInstance, FastifyPluginAsync, FastifyPluginOptions } from 'fastify';
|
||||||
import { FastifyInstance, FastifyPluginOptions } from 'fastify';
|
|
||||||
import { FastifyPluginAsync } from 'fastify';
|
|
||||||
import { OwnershipCostsController } from './ownership-costs.controller';
|
import { OwnershipCostsController } from './ownership-costs.controller';
|
||||||
|
|
||||||
export const ownershipCostsRoutes: FastifyPluginAsync = async (
|
export const ownershipCostsRoutes: FastifyPluginAsync = async (
|
||||||
fastify: FastifyInstance,
|
fastify: FastifyInstance,
|
||||||
_opts: FastifyPluginOptions
|
_opts: FastifyPluginOptions
|
||||||
) => {
|
) => {
|
||||||
const controller = new OwnershipCostsController();
|
const ctrl = new OwnershipCostsController();
|
||||||
|
const requireAuth = fastify.authenticate.bind(fastify);
|
||||||
|
|
||||||
// POST /api/ownership-costs - Create new ownership cost
|
fastify.get('/ownership-costs', {
|
||||||
fastify.post('/ownership-costs', {
|
preHandler: [requireAuth],
|
||||||
preHandler: [fastify.authenticate],
|
handler: ctrl.list.bind(ctrl)
|
||||||
handler: controller.create.bind(controller)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/ownership-costs/:id - Get specific ownership cost
|
fastify.get<{ Params: any }>('/ownership-costs/:id', {
|
||||||
fastify.get('/ownership-costs/:id', {
|
preHandler: [requireAuth],
|
||||||
preHandler: [fastify.authenticate],
|
handler: ctrl.get.bind(ctrl)
|
||||||
handler: controller.getById.bind(controller)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// PUT /api/ownership-costs/:id - Update ownership cost
|
fastify.post<{ Body: any }>('/ownership-costs', {
|
||||||
fastify.put('/ownership-costs/:id', {
|
preHandler: [requireAuth],
|
||||||
preHandler: [fastify.authenticate],
|
handler: ctrl.create.bind(ctrl)
|
||||||
handler: controller.update.bind(controller)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DELETE /api/ownership-costs/:id - Delete ownership cost
|
fastify.put<{ Params: any; Body: any }>('/ownership-costs/:id', {
|
||||||
fastify.delete('/ownership-costs/:id', {
|
preHandler: [requireAuth],
|
||||||
preHandler: [fastify.authenticate],
|
handler: ctrl.update.bind(ctrl)
|
||||||
handler: controller.delete.bind(controller)
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// GET /api/ownership-costs/vehicle/:vehicleId - Get costs for a vehicle
|
fastify.delete<{ Params: any }>('/ownership-costs/:id', {
|
||||||
fastify.get('/ownership-costs/vehicle/:vehicleId', {
|
preHandler: [requireAuth],
|
||||||
preHandler: [fastify.authenticate],
|
handler: ctrl.remove.bind(ctrl)
|
||||||
handler: controller.getByVehicle.bind(controller)
|
|
||||||
});
|
|
||||||
|
|
||||||
// GET /api/ownership-costs/vehicle/:vehicleId/stats - Get aggregated cost stats
|
|
||||||
fastify.get('/ownership-costs/vehicle/:vehicleId/stats', {
|
|
||||||
preHandler: [fastify.authenticate],
|
|
||||||
handler: controller.getVehicleStats.bind(controller)
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// For backward compatibility during migration
|
|
||||||
export function registerOwnershipCostsRoutes() {
|
|
||||||
throw new Error('registerOwnershipCostsRoutes is deprecated - use ownershipCostsRoutes Fastify plugin instead');
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import { CreateOwnershipCostSchema, UpdateOwnershipCostSchema, OwnershipCostTypeSchema } from '../domain/ownership-costs.types';
|
||||||
|
|
||||||
|
export const ListQuerySchema = z.object({
|
||||||
|
vehicleId: z.string().uuid().optional(),
|
||||||
|
costType: OwnershipCostTypeSchema.optional(),
|
||||||
|
documentId: z.string().uuid().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const IdParamsSchema = z.object({ id: z.string().uuid() });
|
||||||
|
|
||||||
|
export const CreateBodySchema = CreateOwnershipCostSchema;
|
||||||
|
export const UpdateBodySchema = UpdateOwnershipCostSchema;
|
||||||
|
|
||||||
|
export type ListQuery = z.infer<typeof ListQuerySchema>;
|
||||||
|
export type IdParams = z.infer<typeof IdParamsSchema>;
|
||||||
|
export type CreateBody = z.infer<typeof CreateBodySchema>;
|
||||||
|
export type UpdateBody = z.infer<typeof UpdateBodySchema>;
|
||||||
@@ -1,210 +1,160 @@
|
|||||||
/**
|
|
||||||
* @ai-summary Data access layer for ownership costs
|
|
||||||
* @ai-context Handles database operations for vehicle ownership costs
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
import { Pool } from 'pg';
|
||||||
import {
|
import pool from '../../../core/config/database';
|
||||||
OwnershipCost,
|
import type { OwnershipCost, OwnershipCostType } from '../domain/ownership-costs.types';
|
||||||
CreateOwnershipCostRequest,
|
|
||||||
UpdateOwnershipCostRequest,
|
|
||||||
CostInterval,
|
|
||||||
OwnershipCostType
|
|
||||||
} from '../domain/ownership-costs.types';
|
|
||||||
|
|
||||||
export class OwnershipCostsRepository {
|
export class OwnershipCostsRepository {
|
||||||
constructor(private pool: Pool) {}
|
constructor(private readonly db: Pool = pool) {}
|
||||||
|
|
||||||
async create(data: CreateOwnershipCostRequest & { userId: string }): Promise<OwnershipCost> {
|
// ========================
|
||||||
const query = `
|
// Row Mappers
|
||||||
INSERT INTO ownership_costs (
|
// ========================
|
||||||
user_id, vehicle_id, document_id, cost_type, description,
|
|
||||||
amount, interval, start_date, end_date
|
|
||||||
)
|
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
const values = [
|
private mapRow(row: any): OwnershipCost {
|
||||||
data.userId,
|
return {
|
||||||
data.vehicleId,
|
id: row.id,
|
||||||
data.documentId ?? null,
|
userId: row.user_id,
|
||||||
data.costType,
|
vehicleId: row.vehicle_id,
|
||||||
data.description ?? null,
|
documentId: row.document_id,
|
||||||
data.amount,
|
costType: row.cost_type,
|
||||||
data.interval,
|
amount: row.amount,
|
||||||
data.startDate,
|
description: row.description,
|
||||||
data.endDate ?? null
|
periodStart: row.period_start,
|
||||||
];
|
periodEnd: row.period_end,
|
||||||
|
notes: row.notes,
|
||||||
const result = await this.pool.query(query, values);
|
createdAt: row.created_at,
|
||||||
return this.mapRow(result.rows[0]);
|
updatedAt: row.updated_at
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async findByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCost[]> {
|
// ========================
|
||||||
const query = `
|
// CRUD Operations
|
||||||
SELECT * FROM ownership_costs
|
// ========================
|
||||||
WHERE vehicle_id = $1 AND user_id = $2
|
|
||||||
ORDER BY start_date DESC, created_at DESC
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.pool.query(query, [vehicleId, userId]);
|
async insert(cost: {
|
||||||
return result.rows.map(row => this.mapRow(row));
|
id: string;
|
||||||
}
|
userId: string;
|
||||||
|
vehicleId: string;
|
||||||
async findByUserId(userId: string): Promise<OwnershipCost[]> {
|
documentId?: string | null;
|
||||||
const query = `
|
costType: OwnershipCostType;
|
||||||
SELECT * FROM ownership_costs
|
amount: number;
|
||||||
WHERE user_id = $1
|
description?: string | null;
|
||||||
ORDER BY start_date DESC, created_at DESC
|
periodStart?: string | null;
|
||||||
`;
|
periodEnd?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
const result = await this.pool.query(query, [userId]);
|
}): Promise<OwnershipCost> {
|
||||||
return result.rows.map(row => this.mapRow(row));
|
const res = await this.db.query(
|
||||||
}
|
`INSERT INTO ownership_costs (
|
||||||
|
id, user_id, vehicle_id, document_id, cost_type, amount, description, period_start, period_end, notes
|
||||||
async findById(id: string): Promise<OwnershipCost | null> {
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
const query = 'SELECT * FROM ownership_costs WHERE id = $1';
|
RETURNING *`,
|
||||||
const result = await this.pool.query(query, [id]);
|
[
|
||||||
|
cost.id,
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.mapRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async findByDocumentId(documentId: string): Promise<OwnershipCost[]> {
|
|
||||||
const query = `
|
|
||||||
SELECT * FROM ownership_costs
|
|
||||||
WHERE document_id = $1
|
|
||||||
ORDER BY start_date DESC
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.pool.query(query, [documentId]);
|
|
||||||
return result.rows.map(row => this.mapRow(row));
|
|
||||||
}
|
|
||||||
|
|
||||||
async update(id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost | null> {
|
|
||||||
const fields = [];
|
|
||||||
const values = [];
|
|
||||||
let paramCount = 1;
|
|
||||||
|
|
||||||
// Build dynamic update query
|
|
||||||
if (data.documentId !== undefined) {
|
|
||||||
fields.push(`document_id = $${paramCount++}`);
|
|
||||||
values.push(data.documentId);
|
|
||||||
}
|
|
||||||
if (data.costType !== undefined) {
|
|
||||||
fields.push(`cost_type = $${paramCount++}`);
|
|
||||||
values.push(data.costType);
|
|
||||||
}
|
|
||||||
if (data.description !== undefined) {
|
|
||||||
fields.push(`description = $${paramCount++}`);
|
|
||||||
values.push(data.description);
|
|
||||||
}
|
|
||||||
if (data.amount !== undefined) {
|
|
||||||
fields.push(`amount = $${paramCount++}`);
|
|
||||||
values.push(data.amount);
|
|
||||||
}
|
|
||||||
if (data.interval !== undefined) {
|
|
||||||
fields.push(`interval = $${paramCount++}`);
|
|
||||||
values.push(data.interval);
|
|
||||||
}
|
|
||||||
if (data.startDate !== undefined) {
|
|
||||||
fields.push(`start_date = $${paramCount++}`);
|
|
||||||
values.push(data.startDate);
|
|
||||||
}
|
|
||||||
if (data.endDate !== undefined) {
|
|
||||||
fields.push(`end_date = $${paramCount++}`);
|
|
||||||
values.push(data.endDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fields.length === 0) {
|
|
||||||
return this.findById(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
values.push(id);
|
|
||||||
const query = `
|
|
||||||
UPDATE ownership_costs
|
|
||||||
SET ${fields.join(', ')}
|
|
||||||
WHERE id = $${paramCount}
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await this.pool.query(query, values);
|
|
||||||
|
|
||||||
if (result.rows.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.mapRow(result.rows[0]);
|
|
||||||
}
|
|
||||||
|
|
||||||
async delete(id: string): Promise<boolean> {
|
|
||||||
const query = 'DELETE FROM ownership_costs WHERE id = $1';
|
|
||||||
const result = await this.pool.query(query, [id]);
|
|
||||||
return (result.rowCount ?? 0) > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
async batchInsert(
|
|
||||||
costs: Array<CreateOwnershipCostRequest & { userId: string }>,
|
|
||||||
client?: Pool
|
|
||||||
): Promise<OwnershipCost[]> {
|
|
||||||
if (costs.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const queryClient = client || this.pool;
|
|
||||||
const placeholders: string[] = [];
|
|
||||||
const values: unknown[] = [];
|
|
||||||
let paramCount = 1;
|
|
||||||
|
|
||||||
costs.forEach((cost) => {
|
|
||||||
const costParams = [
|
|
||||||
cost.userId,
|
cost.userId,
|
||||||
cost.vehicleId,
|
cost.vehicleId,
|
||||||
cost.documentId ?? null,
|
cost.documentId ?? null,
|
||||||
cost.costType,
|
cost.costType,
|
||||||
cost.description ?? null,
|
|
||||||
cost.amount,
|
cost.amount,
|
||||||
cost.interval,
|
cost.description ?? null,
|
||||||
cost.startDate,
|
cost.periodStart ?? null,
|
||||||
cost.endDate ?? null
|
cost.periodEnd ?? null,
|
||||||
];
|
cost.notes ?? null,
|
||||||
|
]
|
||||||
const placeholder = `($${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++}, $${paramCount++})`;
|
);
|
||||||
placeholders.push(placeholder);
|
return this.mapRow(res.rows[0]);
|
||||||
values.push(...costParams);
|
|
||||||
});
|
|
||||||
|
|
||||||
const query = `
|
|
||||||
INSERT INTO ownership_costs (
|
|
||||||
user_id, vehicle_id, document_id, cost_type, description,
|
|
||||||
amount, interval, start_date, end_date
|
|
||||||
)
|
|
||||||
VALUES ${placeholders.join(', ')}
|
|
||||||
RETURNING *
|
|
||||||
`;
|
|
||||||
|
|
||||||
const result = await queryClient.query(query, values);
|
|
||||||
return result.rows.map((row: Record<string, unknown>) => this.mapRow(row));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private mapRow(row: Record<string, unknown>): OwnershipCost {
|
async findById(id: string, userId: string): Promise<OwnershipCost | null> {
|
||||||
return {
|
const res = await this.db.query(
|
||||||
id: row.id as string,
|
`SELECT * FROM ownership_costs WHERE id = $1 AND user_id = $2`,
|
||||||
userId: row.user_id as string,
|
[id, userId]
|
||||||
vehicleId: row.vehicle_id as string,
|
);
|
||||||
documentId: row.document_id as string | undefined,
|
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||||
costType: row.cost_type as OwnershipCostType,
|
}
|
||||||
description: row.description as string | undefined,
|
|
||||||
amount: Number(row.amount),
|
async findByUserId(
|
||||||
interval: row.interval as CostInterval,
|
userId: string,
|
||||||
startDate: row.start_date as Date,
|
filters?: { vehicleId?: string; costType?: OwnershipCostType; documentId?: string }
|
||||||
endDate: row.end_date as Date | undefined,
|
): Promise<OwnershipCost[]> {
|
||||||
createdAt: row.created_at as Date,
|
const conds: string[] = ['user_id = $1'];
|
||||||
updatedAt: row.updated_at as Date,
|
const params: any[] = [userId];
|
||||||
};
|
let i = 2;
|
||||||
|
|
||||||
|
if (filters?.vehicleId) {
|
||||||
|
conds.push(`vehicle_id = $${i++}`);
|
||||||
|
params.push(filters.vehicleId);
|
||||||
|
}
|
||||||
|
if (filters?.costType) {
|
||||||
|
conds.push(`cost_type = $${i++}`);
|
||||||
|
params.push(filters.costType);
|
||||||
|
}
|
||||||
|
if (filters?.documentId) {
|
||||||
|
conds.push(`document_id = $${i++}`);
|
||||||
|
params.push(filters.documentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sql = `SELECT * FROM ownership_costs WHERE ${conds.join(' AND ')} ORDER BY period_start DESC, created_at DESC`;
|
||||||
|
const res = await this.db.query(sql, params);
|
||||||
|
return res.rows.map(row => this.mapRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCost[]> {
|
||||||
|
const res = await this.db.query(
|
||||||
|
`SELECT * FROM ownership_costs WHERE vehicle_id = $1 AND user_id = $2 ORDER BY period_start DESC, created_at DESC`,
|
||||||
|
[vehicleId, userId]
|
||||||
|
);
|
||||||
|
return res.rows.map(row => this.mapRow(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
id: string,
|
||||||
|
userId: string,
|
||||||
|
patch: Partial<Pick<OwnershipCost, 'documentId' | 'costType' | 'amount' | 'description' | 'periodStart' | 'periodEnd' | 'notes'>>
|
||||||
|
): Promise<OwnershipCost | null> {
|
||||||
|
const fields: string[] = [];
|
||||||
|
const params: any[] = [];
|
||||||
|
let i = 1;
|
||||||
|
|
||||||
|
if (patch.documentId !== undefined) {
|
||||||
|
fields.push(`document_id = $${i++}`);
|
||||||
|
params.push(patch.documentId);
|
||||||
|
}
|
||||||
|
if (patch.costType !== undefined) {
|
||||||
|
fields.push(`cost_type = $${i++}`);
|
||||||
|
params.push(patch.costType);
|
||||||
|
}
|
||||||
|
if (patch.amount !== undefined) {
|
||||||
|
fields.push(`amount = $${i++}`);
|
||||||
|
params.push(patch.amount);
|
||||||
|
}
|
||||||
|
if (patch.description !== undefined) {
|
||||||
|
fields.push(`description = $${i++}`);
|
||||||
|
params.push(patch.description);
|
||||||
|
}
|
||||||
|
if (patch.periodStart !== undefined) {
|
||||||
|
fields.push(`period_start = $${i++}`);
|
||||||
|
params.push(patch.periodStart);
|
||||||
|
}
|
||||||
|
if (patch.periodEnd !== undefined) {
|
||||||
|
fields.push(`period_end = $${i++}`);
|
||||||
|
params.push(patch.periodEnd);
|
||||||
|
}
|
||||||
|
if (patch.notes !== undefined) {
|
||||||
|
fields.push(`notes = $${i++}`);
|
||||||
|
params.push(patch.notes);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fields.length) return this.findById(id, userId);
|
||||||
|
|
||||||
|
params.push(id, userId);
|
||||||
|
const sql = `UPDATE ownership_costs SET ${fields.join(', ')} WHERE id = $${i++} AND user_id = $${i++} RETURNING *`;
|
||||||
|
const res = await this.db.query(sql, params);
|
||||||
|
return res.rows[0] ? this.mapRow(res.rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(id: string, userId: string): Promise<void> {
|
||||||
|
await this.db.query(
|
||||||
|
`DELETE FROM ownership_costs WHERE id = $1 AND user_id = $2`,
|
||||||
|
[id, userId]
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,199 +1,141 @@
|
|||||||
/**
|
import { randomUUID } from 'crypto';
|
||||||
* @ai-summary Business logic for ownership costs feature
|
import type { Pool } from 'pg';
|
||||||
* @ai-context Handles ownership cost operations and TCO aggregation
|
import type {
|
||||||
*/
|
|
||||||
|
|
||||||
import { Pool } from 'pg';
|
|
||||||
import { OwnershipCostsRepository } from '../data/ownership-costs.repository';
|
|
||||||
import {
|
|
||||||
OwnershipCost,
|
|
||||||
CreateOwnershipCostRequest,
|
CreateOwnershipCostRequest,
|
||||||
UpdateOwnershipCostRequest,
|
UpdateOwnershipCostRequest,
|
||||||
|
OwnershipCost,
|
||||||
OwnershipCostResponse,
|
OwnershipCostResponse,
|
||||||
OwnershipCostStats,
|
OwnershipCostType,
|
||||||
PAYMENTS_PER_YEAR,
|
OwnershipCostStats
|
||||||
OwnershipCostType
|
|
||||||
} from './ownership-costs.types';
|
} from './ownership-costs.types';
|
||||||
import { logger } from '../../../core/logging/logger';
|
import { OwnershipCostsRepository } from '../data/ownership-costs.repository';
|
||||||
import { VehiclesRepository } from '../../vehicles/data/vehicles.repository';
|
import pool from '../../../core/config/database';
|
||||||
|
|
||||||
export class OwnershipCostsService {
|
export class OwnershipCostsService {
|
||||||
private repository: OwnershipCostsRepository;
|
private readonly repo: OwnershipCostsRepository;
|
||||||
private vehiclesRepository: VehiclesRepository;
|
private readonly db: Pool;
|
||||||
|
|
||||||
constructor(pool: Pool) {
|
constructor(dbPool?: Pool) {
|
||||||
this.repository = new OwnershipCostsRepository(pool);
|
this.db = dbPool || pool;
|
||||||
this.vehiclesRepository = new VehiclesRepository(pool);
|
this.repo = new OwnershipCostsRepository(this.db);
|
||||||
}
|
}
|
||||||
|
|
||||||
async create(data: CreateOwnershipCostRequest, userId: string): Promise<OwnershipCostResponse> {
|
async createCost(userId: string, body: CreateOwnershipCostRequest): Promise<OwnershipCost> {
|
||||||
logger.info('Creating ownership cost', { userId, vehicleId: data.vehicleId, costType: data.costType });
|
await this.assertVehicleOwnership(userId, body.vehicleId);
|
||||||
|
|
||||||
// Verify vehicle ownership
|
const id = randomUUID();
|
||||||
const vehicle = await this.vehiclesRepository.findById(data.vehicleId);
|
const cost = await this.repo.insert({
|
||||||
if (!vehicle) {
|
id,
|
||||||
throw new Error('Vehicle not found');
|
userId,
|
||||||
}
|
vehicleId: body.vehicleId,
|
||||||
if (vehicle.userId !== userId) {
|
documentId: body.documentId,
|
||||||
throw new Error('Unauthorized');
|
costType: body.costType,
|
||||||
}
|
amount: body.amount,
|
||||||
|
description: body.description,
|
||||||
|
periodStart: body.periodStart,
|
||||||
|
periodEnd: body.periodEnd,
|
||||||
|
notes: body.notes,
|
||||||
|
});
|
||||||
|
|
||||||
const cost = await this.repository.create({ ...data, userId });
|
return cost;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getCost(userId: string, id: string): Promise<OwnershipCostResponse | null> {
|
||||||
|
const cost = await this.repo.findById(id, userId);
|
||||||
|
if (!cost) return null;
|
||||||
return this.toResponse(cost);
|
return this.toResponse(cost);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getByVehicleId(vehicleId: string, userId: string): Promise<OwnershipCostResponse[]> {
|
async getCosts(userId: string, filters?: { vehicleId?: string; costType?: OwnershipCostType; documentId?: string }): Promise<OwnershipCostResponse[]> {
|
||||||
// Verify vehicle ownership
|
const costs = await this.repo.findByUserId(userId, filters);
|
||||||
const vehicle = await this.vehiclesRepository.findById(vehicleId);
|
return costs.map(c => this.toResponse(c));
|
||||||
if (!vehicle) {
|
|
||||||
throw new Error('Vehicle not found');
|
|
||||||
}
|
|
||||||
if (vehicle.userId !== userId) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const costs = await this.repository.findByVehicleId(vehicleId, userId);
|
|
||||||
return costs.map(cost => this.toResponse(cost));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getById(id: string, userId: string): Promise<OwnershipCostResponse> {
|
async getCostsByVehicle(userId: string, vehicleId: string): Promise<OwnershipCostResponse[]> {
|
||||||
const cost = await this.repository.findById(id);
|
const costs = await this.repo.findByVehicleId(vehicleId, userId);
|
||||||
|
return costs.map(c => this.toResponse(c));
|
||||||
if (!cost) {
|
|
||||||
throw new Error('Ownership cost not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cost.userId !== userId) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.toResponse(cost);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(id: string, data: UpdateOwnershipCostRequest, userId: string): Promise<OwnershipCostResponse> {
|
async getVehicleOwnershipCosts(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
|
||||||
// Verify ownership
|
const costs = await this.repo.findByVehicleId(vehicleId, userId);
|
||||||
const existing = await this.repository.findById(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error('Ownership cost not found');
|
|
||||||
}
|
|
||||||
if (existing.userId !== userId) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await this.repository.update(id, data);
|
let totalCost = 0;
|
||||||
if (!updated) {
|
let insuranceCosts = 0;
|
||||||
throw new Error('Update failed');
|
let registrationCosts = 0;
|
||||||
}
|
let taxCosts = 0;
|
||||||
|
let otherCosts = 0;
|
||||||
|
|
||||||
return this.toResponse(updated);
|
for (const c of costs) {
|
||||||
}
|
if (c.amount === null || c.amount === undefined) continue;
|
||||||
|
const amount = Number(c.amount);
|
||||||
|
if (isNaN(amount)) {
|
||||||
|
throw new Error(`Invalid amount value for ownership cost ${c.id}`);
|
||||||
|
}
|
||||||
|
|
||||||
async delete(id: string, userId: string): Promise<void> {
|
totalCost += amount;
|
||||||
// Verify ownership
|
|
||||||
const existing = await this.repository.findById(id);
|
|
||||||
if (!existing) {
|
|
||||||
throw new Error('Ownership cost not found');
|
|
||||||
}
|
|
||||||
if (existing.userId !== userId) {
|
|
||||||
throw new Error('Unauthorized');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.repository.delete(id);
|
// Breakdown by cost type for backward compatibility
|
||||||
logger.info('Ownership cost deleted', { id, userId });
|
switch (c.costType) {
|
||||||
}
|
case 'insurance':
|
||||||
|
insuranceCosts += amount;
|
||||||
/**
|
break;
|
||||||
* Get aggregated cost statistics for a vehicle
|
case 'registration':
|
||||||
* Used by TCO calculation in vehicles service
|
registrationCosts += amount;
|
||||||
*/
|
break;
|
||||||
async getVehicleCostStats(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
|
case 'tax':
|
||||||
const costs = await this.repository.findByVehicleId(vehicleId, userId);
|
taxCosts += amount;
|
||||||
const now = new Date();
|
break;
|
||||||
|
case 'inspection':
|
||||||
const stats: OwnershipCostStats = {
|
case 'parking':
|
||||||
insuranceCosts: 0,
|
case 'other':
|
||||||
registrationCosts: 0,
|
otherCosts += amount;
|
||||||
taxCosts: 0,
|
break;
|
||||||
otherCosts: 0,
|
|
||||||
totalCosts: 0
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const cost of costs) {
|
|
||||||
const startDate = new Date(cost.startDate);
|
|
||||||
const endDate = cost.endDate ? new Date(cost.endDate) : now;
|
|
||||||
|
|
||||||
// Skip future costs
|
|
||||||
if (startDate > now) continue;
|
|
||||||
|
|
||||||
// Calculate effective end date (either specified end, or now for ongoing costs)
|
|
||||||
const effectiveEnd = endDate < now ? endDate : now;
|
|
||||||
const monthsCovered = this.calculateMonthsBetween(startDate, effectiveEnd);
|
|
||||||
const normalizedCost = this.normalizeToTotal(cost.amount, cost.interval, monthsCovered);
|
|
||||||
|
|
||||||
// Type-safe key access
|
|
||||||
const keyMap: Record<OwnershipCostType, keyof Omit<OwnershipCostStats, 'totalCosts'>> = {
|
|
||||||
insurance: 'insuranceCosts',
|
|
||||||
registration: 'registrationCosts',
|
|
||||||
tax: 'taxCosts',
|
|
||||||
other: 'otherCosts'
|
|
||||||
};
|
|
||||||
|
|
||||||
const key = keyMap[cost.costType];
|
|
||||||
if (key) {
|
|
||||||
stats[key] += normalizedCost;
|
|
||||||
} else {
|
|
||||||
logger.warn('Unknown cost type in aggregation', { costType: cost.costType, costId: cost.id });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats.totalCosts = stats.insuranceCosts + stats.registrationCosts + stats.taxCosts + stats.otherCosts;
|
return {
|
||||||
return stats;
|
totalCost,
|
||||||
|
recordCount: costs.length,
|
||||||
|
insuranceCosts,
|
||||||
|
registrationCosts,
|
||||||
|
taxCosts,
|
||||||
|
otherCosts
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
// Alias for backward compatibility with vehicles service
|
||||||
* Calculate months between two dates
|
async getVehicleCostStats(vehicleId: string, userId: string): Promise<OwnershipCostStats> {
|
||||||
*/
|
return this.getVehicleOwnershipCosts(vehicleId, userId);
|
||||||
private calculateMonthsBetween(startDate: Date, endDate: Date): number {
|
|
||||||
const yearDiff = endDate.getFullYear() - startDate.getFullYear();
|
|
||||||
const monthDiff = endDate.getMonth() - startDate.getMonth();
|
|
||||||
return Math.max(1, yearDiff * 12 + monthDiff);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async updateCost(userId: string, id: string, patch: UpdateOwnershipCostRequest): Promise<OwnershipCostResponse | null> {
|
||||||
* Normalize recurring cost to total based on interval and months covered
|
const existing = await this.repo.findById(id, userId);
|
||||||
*/
|
if (!existing) return null;
|
||||||
private normalizeToTotal(amount: number, interval: string, monthsCovered: number): number {
|
|
||||||
// One-time costs are just the amount
|
|
||||||
if (interval === 'one_time') {
|
|
||||||
return amount;
|
|
||||||
}
|
|
||||||
|
|
||||||
const paymentsPerYear = PAYMENTS_PER_YEAR[interval as keyof typeof PAYMENTS_PER_YEAR];
|
// Convert nulls to undefined for repository compatibility
|
||||||
if (!paymentsPerYear) {
|
const cleanPatch = Object.fromEntries(
|
||||||
logger.warn('Invalid cost interval', { interval });
|
Object.entries(patch).map(([k, v]) => [k, v === null ? undefined : v])
|
||||||
return 0;
|
) as Partial<Pick<OwnershipCost, 'documentId' | 'costType' | 'amount' | 'description' | 'periodStart' | 'periodEnd' | 'notes'>>;
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total payments over the covered period
|
const updated = await this.repo.update(id, userId, cleanPatch);
|
||||||
const yearsOwned = monthsCovered / 12;
|
if (!updated) return null;
|
||||||
const totalPayments = yearsOwned * paymentsPerYear;
|
return this.toResponse(updated);
|
||||||
return amount * totalPayments;
|
}
|
||||||
|
|
||||||
|
async deleteCost(userId: string, id: string): Promise<void> {
|
||||||
|
await this.repo.delete(id, userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertVehicleOwnership(userId: string, vehicleId: string) {
|
||||||
|
const res = await this.db.query('SELECT id FROM vehicles WHERE id = $1 AND user_id = $2', [vehicleId, userId]);
|
||||||
|
if (!res.rows[0]) {
|
||||||
|
const err: any = new Error('Vehicle not found or not owned by user');
|
||||||
|
err.statusCode = 403;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private toResponse(cost: OwnershipCost): OwnershipCostResponse {
|
private toResponse(cost: OwnershipCost): OwnershipCostResponse {
|
||||||
return {
|
return cost;
|
||||||
id: cost.id,
|
|
||||||
userId: cost.userId,
|
|
||||||
vehicleId: cost.vehicleId,
|
|
||||||
documentId: cost.documentId,
|
|
||||||
costType: cost.costType,
|
|
||||||
description: cost.description,
|
|
||||||
amount: cost.amount,
|
|
||||||
interval: cost.interval,
|
|
||||||
startDate: cost.startDate instanceof Date ? cost.startDate.toISOString().split('T')[0] : cost.startDate as unknown as string,
|
|
||||||
endDate: cost.endDate ? (cost.endDate instanceof Date ? cost.endDate.toISOString().split('T')[0] : cost.endDate as unknown as string) : undefined,
|
|
||||||
createdAt: cost.createdAt instanceof Date ? cost.createdAt.toISOString() : cost.createdAt as unknown as string,
|
|
||||||
updatedAt: cost.updatedAt instanceof Date ? cost.updatedAt.toISOString() : cost.updatedAt as unknown as string,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +1,69 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Type definitions for ownership-costs feature
|
* @ai-summary Type definitions for ownership-costs feature
|
||||||
* @ai-context Tracks vehicle ownership costs (insurance, registration, tax, other)
|
* @ai-context Tracks vehicle ownership costs (insurance, registration, tax, inspection, parking, other)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Cost types supported by ownership-costs feature
|
import { z } from 'zod';
|
||||||
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'other';
|
|
||||||
|
|
||||||
// Cost interval types (one_time added for things like purchase price)
|
// Ownership cost types
|
||||||
export type CostInterval = 'monthly' | 'semi_annual' | 'annual' | 'one_time';
|
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'inspection' | 'parking' | 'other';
|
||||||
|
|
||||||
// Payments per year for each interval type
|
|
||||||
export const PAYMENTS_PER_YEAR: Record<CostInterval, number> = {
|
|
||||||
monthly: 12,
|
|
||||||
semi_annual: 2,
|
|
||||||
annual: 1,
|
|
||||||
one_time: 0, // Special case: calculated differently
|
|
||||||
} as const;
|
|
||||||
|
|
||||||
|
// Database record type (camelCase for TypeScript)
|
||||||
export interface OwnershipCost {
|
export interface OwnershipCost {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
costType: OwnershipCostType;
|
costType: OwnershipCostType;
|
||||||
description?: string;
|
|
||||||
amount: number;
|
amount: number;
|
||||||
interval: CostInterval;
|
|
||||||
startDate: Date;
|
|
||||||
endDate?: Date;
|
|
||||||
createdAt: Date;
|
|
||||||
updatedAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateOwnershipCostRequest {
|
|
||||||
vehicleId: string;
|
|
||||||
documentId?: string;
|
|
||||||
costType: OwnershipCostType;
|
|
||||||
description?: string;
|
description?: string;
|
||||||
amount: number;
|
periodStart?: string;
|
||||||
interval: CostInterval;
|
periodEnd?: string;
|
||||||
startDate: string; // ISO date string
|
notes?: string;
|
||||||
endDate?: string; // ISO date string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateOwnershipCostRequest {
|
|
||||||
documentId?: string | null;
|
|
||||||
costType?: OwnershipCostType;
|
|
||||||
description?: string | null;
|
|
||||||
amount?: number;
|
|
||||||
interval?: CostInterval;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OwnershipCostResponse {
|
|
||||||
id: string;
|
|
||||||
userId: string;
|
|
||||||
vehicleId: string;
|
|
||||||
documentId?: string;
|
|
||||||
costType: OwnershipCostType;
|
|
||||||
description?: string;
|
|
||||||
amount: number;
|
|
||||||
interval: CostInterval;
|
|
||||||
startDate: string;
|
|
||||||
endDate?: string;
|
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregated cost statistics for TCO calculation
|
// Zod schemas for validation (camelCase for API)
|
||||||
|
export const OwnershipCostTypeSchema = z.enum(['insurance', 'registration', 'tax', 'inspection', 'parking', 'other']);
|
||||||
|
|
||||||
|
export const CreateOwnershipCostSchema = z.object({
|
||||||
|
vehicleId: z.string().uuid(),
|
||||||
|
documentId: z.string().uuid().optional(),
|
||||||
|
costType: OwnershipCostTypeSchema,
|
||||||
|
amount: z.number().positive(),
|
||||||
|
description: z.string().max(200).optional(),
|
||||||
|
periodStart: z.string().optional(),
|
||||||
|
periodEnd: z.string().optional(),
|
||||||
|
notes: z.string().max(10000).optional(),
|
||||||
|
});
|
||||||
|
export type CreateOwnershipCostRequest = z.infer<typeof CreateOwnershipCostSchema>;
|
||||||
|
|
||||||
|
export const UpdateOwnershipCostSchema = z.object({
|
||||||
|
documentId: z.string().uuid().nullable().optional(),
|
||||||
|
costType: OwnershipCostTypeSchema.optional(),
|
||||||
|
amount: z.number().positive().optional(),
|
||||||
|
description: z.string().max(200).nullable().optional(),
|
||||||
|
periodStart: z.string().nullable().optional(),
|
||||||
|
periodEnd: z.string().nullable().optional(),
|
||||||
|
notes: z.string().max(10000).nullable().optional(),
|
||||||
|
});
|
||||||
|
export type UpdateOwnershipCostRequest = z.infer<typeof UpdateOwnershipCostSchema>;
|
||||||
|
|
||||||
|
// Response type (alias to OwnershipCost)
|
||||||
|
export type OwnershipCostResponse = OwnershipCost;
|
||||||
|
|
||||||
|
// TCO aggregation stats
|
||||||
|
// NOTE: Extended for backward compatibility with vehicles service
|
||||||
|
// The spec calls for totalCost and recordCount only, but the vehicles service
|
||||||
|
// expects breakdown by cost type. This will be cleaned up when vehicles service
|
||||||
|
// is refactored to use the new ownership-costs table directly.
|
||||||
export interface OwnershipCostStats {
|
export interface OwnershipCostStats {
|
||||||
insuranceCosts: number;
|
totalCost: number;
|
||||||
registrationCosts: number;
|
recordCount: number;
|
||||||
taxCosts: number;
|
// Backward compatibility fields for vehicles service
|
||||||
otherCosts: number;
|
insuranceCosts?: number;
|
||||||
totalCosts: number;
|
registrationCosts?: number;
|
||||||
}
|
taxCosts?: number;
|
||||||
|
otherCosts?: number;
|
||||||
// Fastify-specific types for HTTP handling
|
|
||||||
export interface CreateOwnershipCostBody {
|
|
||||||
vehicleId: string;
|
|
||||||
documentId?: string;
|
|
||||||
costType: OwnershipCostType;
|
|
||||||
description?: string;
|
|
||||||
amount: number;
|
|
||||||
interval: CostInterval;
|
|
||||||
startDate: string;
|
|
||||||
endDate?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface UpdateOwnershipCostBody {
|
|
||||||
documentId?: string | null;
|
|
||||||
costType?: OwnershipCostType;
|
|
||||||
description?: string | null;
|
|
||||||
amount?: number;
|
|
||||||
interval?: CostInterval;
|
|
||||||
startDate?: string;
|
|
||||||
endDate?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OwnershipCostParams {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface VehicleParams {
|
|
||||||
vehicleId: string;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,11 +12,8 @@ export type {
|
|||||||
UpdateOwnershipCostRequest,
|
UpdateOwnershipCostRequest,
|
||||||
OwnershipCostResponse,
|
OwnershipCostResponse,
|
||||||
OwnershipCostStats,
|
OwnershipCostStats,
|
||||||
OwnershipCostType,
|
OwnershipCostType
|
||||||
CostInterval
|
|
||||||
} from './domain/ownership-costs.types';
|
} from './domain/ownership-costs.types';
|
||||||
|
|
||||||
export { PAYMENTS_PER_YEAR } from './domain/ownership-costs.types';
|
|
||||||
|
|
||||||
// Internal: Register routes with Fastify app
|
// Internal: Register routes with Fastify app
|
||||||
export { ownershipCostsRoutes, registerOwnershipCostsRoutes } from './api/ownership-costs.routes';
|
export { ownershipCostsRoutes } from './api/ownership-costs.routes';
|
||||||
|
|||||||
@@ -1,60 +1,27 @@
|
|||||||
-- Migration: Create ownership_costs table
|
-- Migration: Create ownership_costs table
|
||||||
-- Issue: #15
|
-- Issue: #29
|
||||||
-- Description: Store vehicle ownership costs (insurance, registration, tax, other)
|
-- Description: Create ownership_costs table for tracking insurance, registration, tax, inspection, parking, and other costs
|
||||||
-- with explicit date ranges and optional document association
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS ownership_costs (
|
CREATE TABLE ownership_costs (
|
||||||
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
user_id VARCHAR(255) NOT NULL,
|
user_id VARCHAR(255) NOT NULL,
|
||||||
vehicle_id UUID NOT NULL,
|
vehicle_id UUID NOT NULL REFERENCES vehicles(id) ON DELETE CASCADE,
|
||||||
document_id UUID,
|
document_id UUID NULL REFERENCES documents(id) ON DELETE CASCADE,
|
||||||
cost_type VARCHAR(50) NOT NULL,
|
cost_type VARCHAR(32) NOT NULL CHECK (cost_type IN ('insurance', 'registration', 'tax', 'inspection', 'parking', 'other')),
|
||||||
description TEXT,
|
amount DECIMAL(10, 2) NOT NULL CHECK (amount > 0),
|
||||||
amount DECIMAL(12, 2) NOT NULL,
|
description VARCHAR(200),
|
||||||
interval VARCHAR(20) NOT NULL,
|
period_start DATE,
|
||||||
start_date DATE NOT NULL,
|
period_end DATE,
|
||||||
end_date DATE,
|
notes TEXT,
|
||||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||||
|
|
||||||
-- Foreign key to vehicles (cascade delete)
|
|
||||||
CONSTRAINT fk_ownership_costs_vehicle
|
|
||||||
FOREIGN KEY (vehicle_id)
|
|
||||||
REFERENCES vehicles(id)
|
|
||||||
ON DELETE CASCADE,
|
|
||||||
|
|
||||||
-- Foreign key to documents (set null on delete)
|
|
||||||
CONSTRAINT fk_ownership_costs_document
|
|
||||||
FOREIGN KEY (document_id)
|
|
||||||
REFERENCES documents(id)
|
|
||||||
ON DELETE SET NULL,
|
|
||||||
|
|
||||||
-- Enforce valid cost types
|
|
||||||
CONSTRAINT chk_ownership_costs_type
|
|
||||||
CHECK (cost_type IN ('insurance', 'registration', 'tax', 'other')),
|
|
||||||
|
|
||||||
-- Enforce valid intervals
|
|
||||||
CONSTRAINT chk_ownership_costs_interval
|
|
||||||
CHECK (interval IN ('monthly', 'semi_annual', 'annual', 'one_time')),
|
|
||||||
|
|
||||||
-- Enforce non-negative amounts
|
|
||||||
CONSTRAINT chk_ownership_costs_amount_non_negative
|
|
||||||
CHECK (amount >= 0),
|
|
||||||
|
|
||||||
-- Enforce end_date >= start_date when end_date is provided
|
|
||||||
CONSTRAINT chk_ownership_costs_date_range
|
|
||||||
CHECK (end_date IS NULL OR end_date >= start_date)
|
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Create indexes for common queries
|
CREATE INDEX idx_ownership_costs_user_id ON ownership_costs(user_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ownership_costs_user_id ON ownership_costs(user_id);
|
CREATE INDEX idx_ownership_costs_vehicle_id ON ownership_costs(vehicle_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ownership_costs_vehicle_id ON ownership_costs(vehicle_id);
|
CREATE INDEX idx_ownership_costs_document_id ON ownership_costs(document_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ownership_costs_cost_type ON ownership_costs(cost_type);
|
CREATE INDEX idx_ownership_costs_cost_type ON ownership_costs(cost_type);
|
||||||
CREATE INDEX IF NOT EXISTS idx_ownership_costs_start_date ON ownership_costs(start_date DESC);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_ownership_costs_document_id ON ownership_costs(document_id) WHERE document_id IS NOT NULL;
|
|
||||||
|
|
||||||
-- Add updated_at trigger
|
|
||||||
DROP TRIGGER IF EXISTS update_ownership_costs_updated_at ON ownership_costs;
|
|
||||||
CREATE TRIGGER update_ownership_costs_updated_at
|
CREATE TRIGGER update_ownership_costs_updated_at
|
||||||
BEFORE UPDATE ON ownership_costs
|
BEFORE UPDATE ON ownership_costs
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
-- Migration: Migrate existing vehicle TCO data to ownership_costs table
|
|
||||||
-- Issue: #15
|
|
||||||
-- Description: Copy insurance and registration costs from vehicles table to ownership_costs
|
|
||||||
|
|
||||||
-- Insert insurance costs from vehicles that have insurance data
|
|
||||||
INSERT INTO ownership_costs (
|
|
||||||
user_id,
|
|
||||||
vehicle_id,
|
|
||||||
document_id,
|
|
||||||
cost_type,
|
|
||||||
description,
|
|
||||||
amount,
|
|
||||||
interval,
|
|
||||||
start_date,
|
|
||||||
end_date
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
v.user_id,
|
|
||||||
v.id AS vehicle_id,
|
|
||||||
NULL AS document_id,
|
|
||||||
'insurance' AS cost_type,
|
|
||||||
'Migrated from vehicle record' AS description,
|
|
||||||
v.insurance_cost AS amount,
|
|
||||||
v.insurance_interval AS interval,
|
|
||||||
COALESCE(v.purchase_date, v.created_at::date) AS start_date,
|
|
||||||
NULL AS end_date
|
|
||||||
FROM vehicles v
|
|
||||||
WHERE v.insurance_cost IS NOT NULL
|
|
||||||
AND v.insurance_cost > 0
|
|
||||||
AND v.insurance_interval IS NOT NULL
|
|
||||||
AND v.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
-- Only migrate if no insurance cost already exists for this vehicle
|
|
||||||
SELECT 1 FROM ownership_costs oc
|
|
||||||
WHERE oc.vehicle_id = v.id
|
|
||||||
AND oc.cost_type = 'insurance'
|
|
||||||
AND oc.description = 'Migrated from vehicle record'
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Insert registration costs from vehicles that have registration data
|
|
||||||
INSERT INTO ownership_costs (
|
|
||||||
user_id,
|
|
||||||
vehicle_id,
|
|
||||||
document_id,
|
|
||||||
cost_type,
|
|
||||||
description,
|
|
||||||
amount,
|
|
||||||
interval,
|
|
||||||
start_date,
|
|
||||||
end_date
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
v.user_id,
|
|
||||||
v.id AS vehicle_id,
|
|
||||||
NULL AS document_id,
|
|
||||||
'registration' AS cost_type,
|
|
||||||
'Migrated from vehicle record' AS description,
|
|
||||||
v.registration_cost AS amount,
|
|
||||||
v.registration_interval AS interval,
|
|
||||||
COALESCE(v.purchase_date, v.created_at::date) AS start_date,
|
|
||||||
NULL AS end_date
|
|
||||||
FROM vehicles v
|
|
||||||
WHERE v.registration_cost IS NOT NULL
|
|
||||||
AND v.registration_cost > 0
|
|
||||||
AND v.registration_interval IS NOT NULL
|
|
||||||
AND v.is_active = true
|
|
||||||
AND NOT EXISTS (
|
|
||||||
-- Only migrate if no registration cost already exists for this vehicle
|
|
||||||
SELECT 1 FROM ownership_costs oc
|
|
||||||
WHERE oc.vehicle_id = v.id
|
|
||||||
AND oc.cost_type = 'registration'
|
|
||||||
AND oc.description = 'Migrated from vehicle record'
|
|
||||||
);
|
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- Migration: Alter ownership_costs table to match updated schema
|
||||||
|
-- Issue: #29
|
||||||
|
-- Description: Fix schema mismatch - rename columns, add notes, update constraints
|
||||||
|
|
||||||
|
-- Rename columns to match code expectations
|
||||||
|
ALTER TABLE ownership_costs RENAME COLUMN start_date TO period_start;
|
||||||
|
ALTER TABLE ownership_costs RENAME COLUMN end_date TO period_end;
|
||||||
|
|
||||||
|
-- Make period_start nullable (was NOT NULL as start_date)
|
||||||
|
ALTER TABLE ownership_costs ALTER COLUMN period_start DROP NOT NULL;
|
||||||
|
|
||||||
|
-- Drop interval column (no longer used in new schema)
|
||||||
|
ALTER TABLE ownership_costs DROP COLUMN IF EXISTS interval;
|
||||||
|
|
||||||
|
-- Add notes column
|
||||||
|
ALTER TABLE ownership_costs ADD COLUMN IF NOT EXISTS notes TEXT;
|
||||||
|
|
||||||
|
-- Update description column type to match new schema
|
||||||
|
ALTER TABLE ownership_costs ALTER COLUMN description TYPE VARCHAR(200);
|
||||||
|
|
||||||
|
-- Drop old check constraints
|
||||||
|
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_type;
|
||||||
|
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_amount_non_negative;
|
||||||
|
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_date_range;
|
||||||
|
ALTER TABLE ownership_costs DROP CONSTRAINT IF EXISTS chk_ownership_costs_interval;
|
||||||
|
|
||||||
|
-- Update cost_type column to VARCHAR(32) and add new constraint with additional types
|
||||||
|
ALTER TABLE ownership_costs ALTER COLUMN cost_type TYPE VARCHAR(32);
|
||||||
|
ALTER TABLE ownership_costs ADD CONSTRAINT ownership_costs_cost_type_check
|
||||||
|
CHECK (cost_type IN ('insurance', 'registration', 'tax', 'inspection', 'parking', 'other'));
|
||||||
|
|
||||||
|
-- Add amount constraint (amount > 0)
|
||||||
|
ALTER TABLE ownership_costs ADD CONSTRAINT ownership_costs_amount_check CHECK (amount > 0);
|
||||||
@@ -1,60 +1,28 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary API calls for ownership-costs feature
|
* @ai-summary API client for ownership-costs feature
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { apiClient } from '../../../core/api/client';
|
import { apiClient } from '../../../core/api/client';
|
||||||
import {
|
import type { CreateOwnershipCostRequest, OwnershipCost, UpdateOwnershipCostRequest } from '../types/ownership-costs.types';
|
||||||
OwnershipCost,
|
|
||||||
CreateOwnershipCostRequest,
|
|
||||||
UpdateOwnershipCostRequest,
|
|
||||||
OwnershipCostStats
|
|
||||||
} from '../types/ownership-costs.types';
|
|
||||||
|
|
||||||
export const ownershipCostsApi = {
|
export const ownershipCostsApi = {
|
||||||
/**
|
async list(filters?: { vehicleId?: string }) {
|
||||||
* Get all ownership costs for a vehicle
|
const res = await apiClient.get<OwnershipCost[]>('/ownership-costs', { params: filters });
|
||||||
*/
|
return res.data;
|
||||||
getByVehicle: async (vehicleId: string): Promise<OwnershipCost[]> => {
|
|
||||||
const response = await apiClient.get(`/ownership-costs/vehicle/${vehicleId}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
async get(id: string) {
|
||||||
/**
|
const res = await apiClient.get<OwnershipCost>(`/ownership-costs/${id}`);
|
||||||
* Get a single ownership cost by ID
|
return res.data;
|
||||||
*/
|
|
||||||
getById: async (id: string): Promise<OwnershipCost> => {
|
|
||||||
const response = await apiClient.get(`/ownership-costs/${id}`);
|
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
async create(payload: CreateOwnershipCostRequest) {
|
||||||
/**
|
const res = await apiClient.post<OwnershipCost>('/ownership-costs', payload);
|
||||||
* Create a new ownership cost
|
return res.data;
|
||||||
*/
|
|
||||||
create: async (data: CreateOwnershipCostRequest): Promise<OwnershipCost> => {
|
|
||||||
const response = await apiClient.post('/ownership-costs', data);
|
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
async update(id: string, payload: UpdateOwnershipCostRequest) {
|
||||||
/**
|
const res = await apiClient.put<OwnershipCost>(`/ownership-costs/${id}`, payload);
|
||||||
* Update an existing ownership cost
|
return res.data;
|
||||||
*/
|
|
||||||
update: async (id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost> => {
|
|
||||||
const response = await apiClient.put(`/ownership-costs/${id}`, data);
|
|
||||||
return response.data;
|
|
||||||
},
|
},
|
||||||
|
async remove(id: string) {
|
||||||
/**
|
|
||||||
* Delete an ownership cost
|
|
||||||
*/
|
|
||||||
delete: async (id: string): Promise<void> => {
|
|
||||||
await apiClient.delete(`/ownership-costs/${id}`);
|
await apiClient.delete(`/ownership-costs/${id}`);
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
|
||||||
* Get aggregated cost stats for a vehicle
|
|
||||||
*/
|
|
||||||
getVehicleStats: async (vehicleId: string): Promise<OwnershipCostStats> => {
|
|
||||||
const response = await apiClient.get(`/ownership-costs/vehicle/${vehicleId}/stats`);
|
|
||||||
return response.data;
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,214 +1,235 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Form component for adding/editing ownership costs
|
* @ai-summary Form component for creating/editing ownership costs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useEffect } from 'react';
|
import React from 'react';
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
import { Button } from '../../../shared-minimal/components/Button';
|
||||||
import {
|
import { LocalizationProvider } from '@mui/x-date-pickers/LocalizationProvider';
|
||||||
OwnershipCost,
|
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs';
|
||||||
OwnershipCostType,
|
import { DatePicker } from '@mui/x-date-pickers/DatePicker';
|
||||||
CostInterval,
|
import dayjs from 'dayjs';
|
||||||
COST_TYPE_LABELS,
|
import { useCreateOwnershipCost } from '../hooks/useOwnershipCosts';
|
||||||
INTERVAL_LABELS
|
import type { OwnershipCostType } from '../types/ownership-costs.types';
|
||||||
} from '../types/ownership-costs.types';
|
import { useVehicles } from '../../vehicles/hooks/useVehicles';
|
||||||
|
import type { Vehicle } from '../../vehicles/types/vehicles.types';
|
||||||
|
import { useDocumentsList } from '../../documents/hooks/useDocuments';
|
||||||
|
import type { DocumentRecord } from '../../documents/types/documents.types';
|
||||||
|
|
||||||
interface OwnershipCostFormProps {
|
interface OwnershipCostFormProps {
|
||||||
vehicleId: string;
|
onSuccess?: () => void;
|
||||||
initialData?: OwnershipCost;
|
onCancel?: () => void;
|
||||||
onSubmit: (data: {
|
|
||||||
costType: OwnershipCostType;
|
|
||||||
description?: string;
|
|
||||||
amount: number;
|
|
||||||
interval: CostInterval;
|
|
||||||
startDate: string;
|
|
||||||
endDate?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
onCancel: () => void;
|
|
||||||
loading?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OwnershipCostForm: React.FC<OwnershipCostFormProps> = ({
|
export const OwnershipCostForm: React.FC<OwnershipCostFormProps> = ({ onSuccess, onCancel }) => {
|
||||||
initialData,
|
const [vehicleID, setVehicleID] = React.useState<string>('');
|
||||||
onSubmit,
|
const [costType, setCostType] = React.useState<OwnershipCostType>('insurance');
|
||||||
onCancel,
|
const [amount, setAmount] = React.useState<string>('');
|
||||||
loading,
|
const [description, setDescription] = React.useState<string>('');
|
||||||
}) => {
|
const [periodStart, setPeriodStart] = React.useState<string>('');
|
||||||
const [costType, setCostType] = useState<OwnershipCostType>(initialData?.costType || 'insurance');
|
const [periodEnd, setPeriodEnd] = React.useState<string>('');
|
||||||
const [description, setDescription] = useState(initialData?.description || '');
|
const [notes, setNotes] = React.useState<string>('');
|
||||||
const [amount, setAmount] = useState(initialData?.amount?.toString() || '');
|
const [documentID, setDocumentID] = React.useState<string>('');
|
||||||
const [interval, setInterval] = useState<CostInterval>(initialData?.interval || 'monthly');
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
const [startDate, setStartDate] = useState(initialData?.startDate || new Date().toISOString().split('T')[0]);
|
|
||||||
const [endDate, setEndDate] = useState(initialData?.endDate || '');
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
const { data: vehicles } = useVehicles();
|
||||||
if (initialData) {
|
const { data: documents } = useDocumentsList({ vehicleId: vehicleID });
|
||||||
setCostType(initialData.costType);
|
const create = useCreateOwnershipCost();
|
||||||
setDescription(initialData.description || '');
|
|
||||||
setAmount(initialData.amount.toString());
|
const resetForm = () => {
|
||||||
setInterval(initialData.interval);
|
setVehicleID('');
|
||||||
setStartDate(initialData.startDate);
|
setCostType('insurance');
|
||||||
setEndDate(initialData.endDate || '');
|
setAmount('');
|
||||||
}
|
setDescription('');
|
||||||
}, [initialData]);
|
setPeriodStart('');
|
||||||
|
setPeriodEnd('');
|
||||||
|
setNotes('');
|
||||||
|
setDocumentID('');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
// Validate amount
|
if (!vehicleID) {
|
||||||
|
setError('Please select a vehicle.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const parsedAmount = parseFloat(amount);
|
const parsedAmount = parseFloat(amount);
|
||||||
if (isNaN(parsedAmount) || parsedAmount < 0) {
|
if (isNaN(parsedAmount) || parsedAmount <= 0) {
|
||||||
setError('Please enter a valid amount');
|
setError('Please enter a valid positive amount.');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate dates
|
|
||||||
if (!startDate) {
|
|
||||||
setError('Start date is required');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (endDate && new Date(endDate) < new Date(startDate)) {
|
|
||||||
setError('End date must be after start date');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await onSubmit({
|
await create.mutateAsync({
|
||||||
costType,
|
vehicleId: vehicleID,
|
||||||
description: description.trim() || undefined,
|
documentId: documentID || undefined,
|
||||||
|
costType: costType,
|
||||||
amount: parsedAmount,
|
amount: parsedAmount,
|
||||||
interval,
|
description: description.trim() || undefined,
|
||||||
startDate,
|
periodStart: periodStart || undefined,
|
||||||
endDate: endDate || undefined,
|
periodEnd: periodEnd || undefined,
|
||||||
|
notes: notes.trim() || undefined,
|
||||||
});
|
});
|
||||||
} catch (err: unknown) {
|
|
||||||
const message = err instanceof Error ? err.message : 'Failed to save cost';
|
resetForm();
|
||||||
setError(message);
|
onSuccess?.();
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err?.message || 'Failed to create ownership cost');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isEditMode = !!initialData;
|
const vehicleLabel = (v: Vehicle) => {
|
||||||
|
if (v.nickname && v.nickname.trim().length > 0) return v.nickname.trim();
|
||||||
|
const parts = [v.year, v.make, v.model, v.trimLevel].filter(Boolean);
|
||||||
|
const primary = parts.join(' ').trim();
|
||||||
|
if (primary.length > 0) return primary;
|
||||||
|
if (v.vin && v.vin.length > 0) return v.vin;
|
||||||
|
return v.id.slice(0, 8) + '...';
|
||||||
|
};
|
||||||
|
|
||||||
|
const documentLabel = (doc: DocumentRecord) => {
|
||||||
|
return doc.title || `Document ${doc.id.slice(0, 8)}`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||||
{error && (
|
<form onSubmit={handleSubmit} className="w-full">
|
||||||
<div className="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-md text-sm">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pb-4">
|
||||||
{error}
|
<div className="flex flex-col">
|
||||||
</div>
|
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Vehicle</label>
|
||||||
)}
|
<select
|
||||||
|
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
|
value={vehicleID}
|
||||||
|
onChange={(e) => setVehicleID(e.target.value)}
|
||||||
|
required
|
||||||
|
>
|
||||||
|
<option value="">Select vehicle...</option>
|
||||||
|
{(vehicles || []).map((v: Vehicle) => (
|
||||||
|
<option key={v.id} value={v.id}>{vehicleLabel(v)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Cost Type</label>
|
||||||
Cost Type
|
<select
|
||||||
</label>
|
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
<select
|
value={costType}
|
||||||
value={costType}
|
onChange={(e) => setCostType(e.target.value as OwnershipCostType)}
|
||||||
onChange={(e) => setCostType(e.target.value as OwnershipCostType)}
|
>
|
||||||
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi"
|
<option value="insurance">Insurance</option>
|
||||||
style={{ fontSize: '16px' }}
|
<option value="registration">Registration</option>
|
||||||
>
|
<option value="tax">Tax</option>
|
||||||
{(Object.entries(COST_TYPE_LABELS) as [OwnershipCostType, string][]).map(([value, label]) => (
|
<option value="inspection">Inspection</option>
|
||||||
<option key={value} value={value}>
|
<option value="parking">Parking</option>
|
||||||
{label}
|
<option value="other">Other</option>
|
||||||
</option>
|
</select>
|
||||||
))}
|
</div>
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
<div className="flex flex-col md:col-span-2">
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Amount</label>
|
||||||
Description (optional)
|
<div className="relative">
|
||||||
</label>
|
<span className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-500 dark:text-titanio">$</span>
|
||||||
<input
|
<input
|
||||||
type="text"
|
className="h-11 min-h-[44px] w-full rounded-lg border pl-7 pr-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
value={description}
|
type="number"
|
||||||
onChange={(e) => setDescription(e.target.value)}
|
step="0.01"
|
||||||
placeholder="e.g., Geico Full Coverage"
|
min="0.01"
|
||||||
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna"
|
value={amount}
|
||||||
style={{ fontSize: '16px' }}
|
placeholder="0.00"
|
||||||
/>
|
onChange={(e) => setAmount(e.target.value)}
|
||||||
</div>
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="flex flex-col md:col-span-2">
|
||||||
<div>
|
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Description (optional)</label>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
<input
|
||||||
Amount
|
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
</label>
|
type="text"
|
||||||
<input
|
value={description}
|
||||||
type="number"
|
placeholder="e.g., State Farm Full Coverage"
|
||||||
value={amount}
|
onChange={(e) => setDescription(e.target.value)}
|
||||||
onChange={(e) => setAmount(e.target.value)}
|
/>
|
||||||
placeholder="0.00"
|
</div>
|
||||||
inputMode="decimal"
|
|
||||||
step="0.01"
|
<div className="flex flex-col">
|
||||||
min="0"
|
<DatePicker
|
||||||
required
|
label="Period Start (optional)"
|
||||||
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:placeholder-canna"
|
value={periodStart ? dayjs(periodStart) : null}
|
||||||
style={{ fontSize: '16px' }}
|
onChange={(newValue) => setPeriodStart(newValue?.format('YYYY-MM-DD') || '')}
|
||||||
/>
|
format="MM/DD/YYYY"
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
fullWidth: true,
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
minHeight: 44,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<DatePicker
|
||||||
|
label="Period End (optional)"
|
||||||
|
value={periodEnd ? dayjs(periodEnd) : null}
|
||||||
|
onChange={(newValue) => setPeriodEnd(newValue?.format('YYYY-MM-DD') || '')}
|
||||||
|
format="MM/DD/YYYY"
|
||||||
|
slotProps={{
|
||||||
|
textField: {
|
||||||
|
fullWidth: true,
|
||||||
|
sx: {
|
||||||
|
'& .MuiOutlinedInput-root': {
|
||||||
|
minHeight: 44,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Notes (optional)</label>
|
||||||
|
<textarea
|
||||||
|
className="min-h-[88px] rounded-lg border px-3 py-2 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col md:col-span-2">
|
||||||
|
<label className="text-sm font-medium text-slate-700 dark:text-avus mb-1">Link to Document (optional)</label>
|
||||||
|
<select
|
||||||
|
className="h-11 min-h-[44px] rounded-lg border px-3 bg-white text-gray-900 border-slate-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi dark:focus:border-abudhabi"
|
||||||
|
value={documentID}
|
||||||
|
onChange={(e) => setDocumentID(e.target.value)}
|
||||||
|
disabled={!vehicleID}
|
||||||
|
>
|
||||||
|
<option value="">No document</option>
|
||||||
|
{(documents || []).map((doc: DocumentRecord) => (
|
||||||
|
<option key={doc.id} value={doc.id}>{documentLabel(doc)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
{error && (
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
<div className="text-red-600 dark:text-red-400 text-sm mt-3">{error}</div>
|
||||||
Payment Interval
|
)}
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={interval}
|
|
||||||
onChange={(e) => setInterval(e.target.value as CostInterval)}
|
|
||||||
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone dark:focus:ring-abudhabi"
|
|
||||||
style={{ fontSize: '16px' }}
|
|
||||||
>
|
|
||||||
{(Object.entries(INTERVAL_LABELS) as [CostInterval, string][]).map(([value, label]) => (
|
|
||||||
<option key={value} value={value}>
|
|
||||||
{label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="flex flex-col sm:flex-row gap-2 mt-4">
|
||||||
<div>
|
<Button type="submit" className="min-h-[44px]">Create Ownership Cost</Button>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
<Button type="button" variant="secondary" onClick={onCancel} className="min-h-[44px]">Cancel</Button>
|
||||||
Start Date
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
required
|
|
||||||
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone"
|
|
||||||
style={{ fontSize: '16px' }}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
</form>
|
||||||
<div>
|
</LocalizationProvider>
|
||||||
<label className="block text-sm font-medium text-gray-700 dark:text-avus mb-1">
|
|
||||||
End Date (optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 border rounded-md min-h-[44px] bg-white text-gray-900 border-gray-300 focus:outline-none focus:ring-2 focus:ring-primary-500 dark:bg-scuro dark:text-avus dark:border-silverstone"
|
|
||||||
style={{ fontSize: '16px' }}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 dark:text-titanio mt-1">
|
|
||||||
Leave blank for ongoing costs
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-3 justify-end pt-4">
|
|
||||||
<Button variant="secondary" onClick={onCancel} type="button">
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" loading={loading}>
|
|
||||||
{isEditMode ? 'Update Cost' : 'Add Cost'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default OwnershipCostForm;
|
||||||
|
|||||||
@@ -2,65 +2,38 @@
|
|||||||
* @ai-summary List component for displaying ownership costs
|
* @ai-summary List component for displaying ownership costs
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React from 'react';
|
||||||
import {
|
import type { OwnershipCost, OwnershipCostType } from '../types/ownership-costs.types';
|
||||||
OwnershipCost,
|
import { useOwnershipCostsList, useDeleteOwnershipCost } from '../hooks/useOwnershipCosts';
|
||||||
CreateOwnershipCostRequest,
|
|
||||||
COST_TYPE_LABELS,
|
|
||||||
INTERVAL_LABELS
|
|
||||||
} from '../types/ownership-costs.types';
|
|
||||||
import { OwnershipCostForm } from './OwnershipCostForm';
|
|
||||||
import { useOwnershipCosts } from '../hooks/useOwnershipCosts';
|
|
||||||
import { Button } from '../../../shared-minimal/components/Button';
|
|
||||||
|
|
||||||
interface OwnershipCostsListProps {
|
interface OwnershipCostsListProps {
|
||||||
vehicleId: string;
|
vehicleId?: string;
|
||||||
|
onEdit?: (cost: OwnershipCost) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({
|
const COST_TYPE_LABELS: Record<OwnershipCostType, string> = {
|
||||||
vehicleId,
|
insurance: 'Insurance',
|
||||||
}) => {
|
registration: 'Registration',
|
||||||
const { costs, isLoading, error, createCost, updateCost, deleteCost } = useOwnershipCosts(vehicleId);
|
tax: 'Tax',
|
||||||
const [showForm, setShowForm] = useState(false);
|
inspection: 'Inspection',
|
||||||
const [editingCost, setEditingCost] = useState<OwnershipCost | null>(null);
|
parking: 'Parking',
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
other: 'Other',
|
||||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
};
|
||||||
|
|
||||||
const handleSubmit = async (data: Omit<CreateOwnershipCostRequest, 'vehicleId'>) => {
|
export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({ vehicleId, onEdit }) => {
|
||||||
setIsSubmitting(true);
|
const { data: costs, isLoading, error } = useOwnershipCostsList(vehicleId ? { vehicleId } : undefined);
|
||||||
try {
|
const deleteMutation = useDeleteOwnershipCost();
|
||||||
if (editingCost) {
|
const [deleteConfirm, setDeleteConfirm] = React.useState<string | null>(null);
|
||||||
await updateCost(editingCost.id, data);
|
|
||||||
} else {
|
|
||||||
await createCost({ ...data, vehicleId });
|
|
||||||
}
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingCost(null);
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleEdit = (cost: OwnershipCost) => {
|
|
||||||
setEditingCost(cost);
|
|
||||||
setShowForm(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteCost(id);
|
await deleteMutation.mutateAsync(id);
|
||||||
setDeleteConfirm(null);
|
setDeleteConfirm(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to delete cost:', err);
|
console.error('Failed to delete cost:', err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCancel = () => {
|
|
||||||
setShowForm(false);
|
|
||||||
setEditingCost(null);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Format currency
|
|
||||||
const formatCurrency = (value: number): string => {
|
const formatCurrency = (value: number): string => {
|
||||||
return value.toLocaleString(undefined, {
|
return value.toLocaleString(undefined, {
|
||||||
minimumFractionDigits: 2,
|
minimumFractionDigits: 2,
|
||||||
@@ -68,7 +41,6 @@ export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Format date
|
|
||||||
const formatDate = (dateString: string): string => {
|
const formatDate = (dateString: string): string => {
|
||||||
return new Date(dateString).toLocaleDateString();
|
return new Date(dateString).toLocaleDateString();
|
||||||
};
|
};
|
||||||
@@ -84,127 +56,95 @@ export const OwnershipCostsList: React.FC<OwnershipCostsListProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
// Show a subtle message if the feature isn't set up yet, don't block the page
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="text-sm text-gray-500 dark:text-titanio p-4 bg-gray-50 dark:bg-scuro rounded-lg border border-gray-200 dark:border-silverstone">
|
||||||
<div className="flex justify-between items-center">
|
<p>Unable to load ownership costs.</p>
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-avus">
|
</div>
|
||||||
Recurring Costs
|
);
|
||||||
</h3>
|
}
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-titanio p-4 bg-gray-50 dark:bg-scuro rounded-lg border border-gray-200 dark:border-silverstone">
|
if (!costs || costs.length === 0) {
|
||||||
<p>Recurring costs tracking is being set up.</p>
|
return (
|
||||||
<p className="text-xs mt-1 text-gray-400 dark:text-canna">Run migrations to enable this feature.</p>
|
<div className="text-center py-8 text-gray-500 dark:text-titanio">
|
||||||
</div>
|
<p>No ownership costs recorded yet.</p>
|
||||||
|
<p className="text-sm mt-1">Track insurance, registration, taxes, and other recurring vehicle costs.</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-3">
|
||||||
<div className="flex justify-between items-center">
|
{costs.map((cost) => (
|
||||||
<h3 className="text-lg font-medium text-gray-900 dark:text-avus">
|
<div
|
||||||
Recurring Costs
|
key={cost.id}
|
||||||
</h3>
|
className="p-4 bg-white dark:bg-jet rounded-lg border border-gray-200 dark:border-silverstone"
|
||||||
{!showForm && (
|
>
|
||||||
<Button
|
<div className="flex justify-between items-start">
|
||||||
variant="secondary"
|
<div className="flex-1">
|
||||||
onClick={() => setShowForm(true)}
|
<div className="flex items-center gap-2">
|
||||||
className="text-sm"
|
<span className="font-medium text-gray-900 dark:text-avus">
|
||||||
>
|
{COST_TYPE_LABELS[cost.costType]}
|
||||||
Add Cost
|
</span>
|
||||||
</Button>
|
</div>
|
||||||
)}
|
{cost.description && (
|
||||||
</div>
|
<p className="text-sm text-gray-500 dark:text-titanio mt-1">
|
||||||
|
{cost.description}
|
||||||
{showForm && (
|
</p>
|
||||||
<div className="p-4 bg-gray-50 dark:bg-scuro rounded-lg border border-gray-200 dark:border-silverstone">
|
)}
|
||||||
<h4 className="text-md font-medium text-gray-900 dark:text-avus mb-4">
|
{(cost.periodStart || cost.periodEnd) && (
|
||||||
{editingCost ? 'Edit Cost' : 'Add New Cost'}
|
<p className="text-xs text-gray-400 dark:text-canna mt-2">
|
||||||
</h4>
|
{cost.periodStart && formatDate(cost.periodStart)}
|
||||||
<OwnershipCostForm
|
{cost.periodStart && cost.periodEnd && ' - '}
|
||||||
vehicleId={vehicleId}
|
{cost.periodEnd && formatDate(cost.periodEnd)}
|
||||||
initialData={editingCost || undefined}
|
</p>
|
||||||
onSubmit={handleSubmit}
|
)}
|
||||||
onCancel={handleCancel}
|
{cost.notes && (
|
||||||
loading={isSubmitting}
|
<p className="text-xs text-gray-500 dark:text-titanio mt-2">
|
||||||
/>
|
{cost.notes}
|
||||||
</div>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
{costs.length === 0 && !showForm ? (
|
<div className="text-right ml-4">
|
||||||
<div className="text-center py-8 text-gray-500 dark:text-titanio">
|
<div className="text-lg font-semibold text-gray-900 dark:text-avus">
|
||||||
<p>No recurring costs added yet.</p>
|
${formatCurrency(cost.amount)}
|
||||||
<p className="text-sm mt-1">Track insurance, registration, and other recurring vehicle costs.</p>
|
</div>
|
||||||
</div>
|
<div className="flex gap-2 mt-2">
|
||||||
) : (
|
{onEdit && (
|
||||||
<div className="space-y-3">
|
<button
|
||||||
{costs.map((cost) => (
|
onClick={() => onEdit(cost)}
|
||||||
<div
|
className="text-sm text-primary-600 dark:text-abudhabi hover:underline min-h-[44px] px-2"
|
||||||
key={cost.id}
|
>
|
||||||
className="p-4 bg-white dark:bg-jet rounded-lg border border-gray-200 dark:border-silverstone"
|
Edit
|
||||||
>
|
</button>
|
||||||
<div className="flex justify-between items-start">
|
)}
|
||||||
<div>
|
{deleteConfirm === cost.id ? (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex gap-1">
|
||||||
<span className="font-medium text-gray-900 dark:text-avus">
|
|
||||||
{COST_TYPE_LABELS[cost.costType]}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs px-2 py-0.5 bg-gray-100 dark:bg-silverstone text-gray-600 dark:text-titanio rounded">
|
|
||||||
{INTERVAL_LABELS[cost.interval]}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{cost.description && (
|
|
||||||
<p className="text-sm text-gray-500 dark:text-titanio mt-1">
|
|
||||||
{cost.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-gray-400 dark:text-canna mt-2">
|
|
||||||
{formatDate(cost.startDate)}
|
|
||||||
{cost.endDate ? ` - ${formatDate(cost.endDate)}` : ' - Ongoing'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="text-right">
|
|
||||||
<div className="text-lg font-semibold text-gray-900 dark:text-avus">
|
|
||||||
${formatCurrency(cost.amount)}
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 mt-2">
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleEdit(cost)}
|
onClick={() => handleDelete(cost.id)}
|
||||||
className="text-sm text-primary-600 dark:text-abudhabi hover:underline"
|
className="text-sm text-red-600 hover:underline min-h-[44px] px-2"
|
||||||
>
|
>
|
||||||
Edit
|
Confirm
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(null)}
|
||||||
|
className="text-sm text-gray-500 hover:underline min-h-[44px] px-2"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
{deleteConfirm === cost.id ? (
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => handleDelete(cost.id)}
|
|
||||||
className="text-sm text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Confirm
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(null)}
|
|
||||||
className="text-sm text-gray-500 hover:underline"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => setDeleteConfirm(cost.id)}
|
|
||||||
className="text-sm text-red-600 hover:underline"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => setDeleteConfirm(cost.id)}
|
||||||
|
className="text-sm text-red-600 hover:underline min-h-[44px] px-2"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,75 +1,150 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary React hook for ownership costs management
|
* @ai-summary React Query hooks for ownership-costs feature
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { ownershipCostsApi } from '../api/ownership-costs.api';
|
import { ownershipCostsApi } from '../api/ownership-costs.api';
|
||||||
import {
|
import type { CreateOwnershipCostRequest, UpdateOwnershipCostRequest, OwnershipCost } from '../types/ownership-costs.types';
|
||||||
OwnershipCost,
|
|
||||||
CreateOwnershipCostRequest,
|
|
||||||
UpdateOwnershipCostRequest
|
|
||||||
} from '../types/ownership-costs.types';
|
|
||||||
|
|
||||||
interface UseOwnershipCostsResult {
|
export function useOwnershipCostsList(filters?: { vehicleId?: string }) {
|
||||||
costs: OwnershipCost[];
|
const queryKey = ['ownership-costs', filters];
|
||||||
isLoading: boolean;
|
const query = useQuery({
|
||||||
error: string | null;
|
queryKey,
|
||||||
refresh: () => Promise<void>;
|
queryFn: () => ownershipCostsApi.list(filters),
|
||||||
createCost: (data: CreateOwnershipCostRequest) => Promise<OwnershipCost>;
|
networkMode: 'offlineFirst',
|
||||||
updateCost: (id: string, data: UpdateOwnershipCostRequest) => Promise<OwnershipCost>;
|
});
|
||||||
deleteCost: (id: string) => Promise<void>;
|
return query;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOwnershipCosts(vehicleId: string): UseOwnershipCostsResult {
|
export function useOwnershipCost(id?: string) {
|
||||||
const [costs, setCosts] = useState<OwnershipCost[]>([]);
|
const query = useQuery({
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
queryKey: ['ownership-cost', id],
|
||||||
const [error, setError] = useState<string | null>(null);
|
queryFn: () => ownershipCostsApi.get(id!),
|
||||||
|
enabled: !!id,
|
||||||
const fetchCosts = useCallback(async () => {
|
networkMode: 'offlineFirst',
|
||||||
if (!vehicleId) return;
|
});
|
||||||
|
return query;
|
||||||
setIsLoading(true);
|
}
|
||||||
setError(null);
|
|
||||||
try {
|
export function useCreateOwnershipCost() {
|
||||||
const data = await ownershipCostsApi.getByVehicle(vehicleId);
|
const qc = useQueryClient();
|
||||||
setCosts(data);
|
return useMutation({
|
||||||
} catch (err: unknown) {
|
mutationFn: (payload: CreateOwnershipCostRequest) => ownershipCostsApi.create(payload),
|
||||||
const message = err instanceof Error ? err.message : 'Failed to load ownership costs';
|
onMutate: async (newCost) => {
|
||||||
setError(message);
|
await qc.cancelQueries({ queryKey: ['ownership-costs'] });
|
||||||
console.error('Failed to fetch ownership costs:', err);
|
|
||||||
} finally {
|
const previousCosts = qc.getQueryData(['ownership-costs']);
|
||||||
setIsLoading(false);
|
|
||||||
}
|
const optimisticCost: OwnershipCost = {
|
||||||
}, [vehicleId]);
|
id: `temp-${Date.now()}`,
|
||||||
|
vehicleId: newCost.vehicleId,
|
||||||
useEffect(() => {
|
documentId: newCost.documentId || null,
|
||||||
fetchCosts();
|
costType: newCost.costType,
|
||||||
}, [fetchCosts]);
|
amount: newCost.amount,
|
||||||
|
description: newCost.description || null,
|
||||||
const createCost = useCallback(async (data: CreateOwnershipCostRequest): Promise<OwnershipCost> => {
|
periodStart: newCost.periodStart || null,
|
||||||
const newCost = await ownershipCostsApi.create(data);
|
periodEnd: newCost.periodEnd || null,
|
||||||
setCosts(prev => [newCost, ...prev]);
|
notes: newCost.notes || null,
|
||||||
return newCost;
|
createdAt: new Date().toISOString(),
|
||||||
}, []);
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
const updateCost = useCallback(async (id: string, data: UpdateOwnershipCostRequest): Promise<OwnershipCost> => {
|
|
||||||
const updated = await ownershipCostsApi.update(id, data);
|
qc.setQueryData(['ownership-costs'], (old: OwnershipCost[] | undefined) => {
|
||||||
setCosts(prev => prev.map(cost => cost.id === id ? updated : cost));
|
return old ? [optimisticCost, ...old] : [optimisticCost];
|
||||||
return updated;
|
});
|
||||||
}, []);
|
|
||||||
|
return { previousCosts };
|
||||||
const deleteCost = useCallback(async (id: string): Promise<void> => {
|
},
|
||||||
await ownershipCostsApi.delete(id);
|
onError: (_err, _newCost, context) => {
|
||||||
setCosts(prev => prev.filter(cost => cost.id !== id));
|
if (context?.previousCosts) {
|
||||||
}, []);
|
qc.setQueryData(['ownership-costs'], context.previousCosts);
|
||||||
|
}
|
||||||
return {
|
},
|
||||||
costs,
|
onSettled: () => {
|
||||||
isLoading,
|
qc.invalidateQueries({ queryKey: ['ownership-costs'] });
|
||||||
error,
|
},
|
||||||
refresh: fetchCosts,
|
networkMode: 'offlineFirst',
|
||||||
createCost,
|
});
|
||||||
updateCost,
|
}
|
||||||
deleteCost,
|
|
||||||
};
|
export function useUpdateOwnershipCost(id: string) {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (payload: UpdateOwnershipCostRequest) => ownershipCostsApi.update(id, payload),
|
||||||
|
onMutate: async (updateData) => {
|
||||||
|
await qc.cancelQueries({ queryKey: ['ownership-cost', id] });
|
||||||
|
await qc.cancelQueries({ queryKey: ['ownership-costs'] });
|
||||||
|
|
||||||
|
const previousCost = qc.getQueryData(['ownership-cost', id]);
|
||||||
|
const previousCosts = qc.getQueryData(['ownership-costs']);
|
||||||
|
|
||||||
|
qc.setQueryData(['ownership-cost', id], (old: OwnershipCost | undefined) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return {
|
||||||
|
...old,
|
||||||
|
...updateData,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
qc.setQueryData(['ownership-costs'], (old: OwnershipCost[] | undefined) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return old.map(cost =>
|
||||||
|
cost.id === id
|
||||||
|
? { ...cost, ...updateData, updatedAt: new Date().toISOString() }
|
||||||
|
: cost
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { previousCost, previousCosts };
|
||||||
|
},
|
||||||
|
onError: (_err, _updateData, context) => {
|
||||||
|
if (context?.previousCost) {
|
||||||
|
qc.setQueryData(['ownership-cost', id], context.previousCost);
|
||||||
|
}
|
||||||
|
if (context?.previousCosts) {
|
||||||
|
qc.setQueryData(['ownership-costs'], context.previousCosts);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['ownership-cost', id] });
|
||||||
|
qc.invalidateQueries({ queryKey: ['ownership-costs'] });
|
||||||
|
},
|
||||||
|
networkMode: 'offlineFirst',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDeleteOwnershipCost() {
|
||||||
|
const qc = useQueryClient();
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: (id: string) => ownershipCostsApi.remove(id),
|
||||||
|
onMutate: async (id) => {
|
||||||
|
await qc.cancelQueries({ queryKey: ['ownership-costs'] });
|
||||||
|
await qc.cancelQueries({ queryKey: ['ownership-cost', id] });
|
||||||
|
|
||||||
|
const previousCosts = qc.getQueryData(['ownership-costs']);
|
||||||
|
const previousCost = qc.getQueryData(['ownership-cost', id]);
|
||||||
|
|
||||||
|
qc.setQueryData(['ownership-costs'], (old: OwnershipCost[] | undefined) => {
|
||||||
|
if (!old) return old;
|
||||||
|
return old.filter(cost => cost.id !== id);
|
||||||
|
});
|
||||||
|
|
||||||
|
qc.removeQueries({ queryKey: ['ownership-cost', id] });
|
||||||
|
|
||||||
|
return { previousCosts, previousCost, deletedId: id };
|
||||||
|
},
|
||||||
|
onError: (_err, _id, context) => {
|
||||||
|
if (context?.previousCosts) {
|
||||||
|
qc.setQueryData(['ownership-costs'], context.previousCosts);
|
||||||
|
}
|
||||||
|
if (context?.previousCost && context.deletedId) {
|
||||||
|
qc.setQueryData(['ownership-cost', context.deletedId], context.previousCost);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
qc.invalidateQueries({ queryKey: ['ownership-costs'] });
|
||||||
|
},
|
||||||
|
networkMode: 'offlineFirst',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* @ai-summary Public API for ownership-costs frontend feature
|
* @ai-summary Public API for ownership-costs feature
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Export components
|
// Export components
|
||||||
@@ -7,7 +7,13 @@ export { OwnershipCostForm } from './components/OwnershipCostForm';
|
|||||||
export { OwnershipCostsList } from './components/OwnershipCostsList';
|
export { OwnershipCostsList } from './components/OwnershipCostsList';
|
||||||
|
|
||||||
// Export hooks
|
// Export hooks
|
||||||
export { useOwnershipCosts } from './hooks/useOwnershipCosts';
|
export {
|
||||||
|
useOwnershipCostsList,
|
||||||
|
useOwnershipCost,
|
||||||
|
useCreateOwnershipCost,
|
||||||
|
useUpdateOwnershipCost,
|
||||||
|
useDeleteOwnershipCost,
|
||||||
|
} from './hooks/useOwnershipCosts';
|
||||||
|
|
||||||
// Export API
|
// Export API
|
||||||
export { ownershipCostsApi } from './api/ownership-costs.api';
|
export { ownershipCostsApi } from './api/ownership-costs.api';
|
||||||
@@ -17,9 +23,5 @@ export type {
|
|||||||
OwnershipCost,
|
OwnershipCost,
|
||||||
CreateOwnershipCostRequest,
|
CreateOwnershipCostRequest,
|
||||||
UpdateOwnershipCostRequest,
|
UpdateOwnershipCostRequest,
|
||||||
OwnershipCostStats,
|
|
||||||
OwnershipCostType,
|
OwnershipCostType,
|
||||||
CostInterval
|
|
||||||
} from './types/ownership-costs.types';
|
} from './types/ownership-costs.types';
|
||||||
|
|
||||||
export { COST_TYPE_LABELS, INTERVAL_LABELS } from './types/ownership-costs.types';
|
|
||||||
|
|||||||
@@ -2,23 +2,18 @@
|
|||||||
* @ai-summary Type definitions for ownership-costs feature
|
* @ai-summary Type definitions for ownership-costs feature
|
||||||
*/
|
*/
|
||||||
|
|
||||||
// Cost types supported by ownership-costs feature
|
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'inspection' | 'parking' | 'other';
|
||||||
export type OwnershipCostType = 'insurance' | 'registration' | 'tax' | 'other';
|
|
||||||
|
|
||||||
// Cost interval types
|
|
||||||
export type CostInterval = 'monthly' | 'semi_annual' | 'annual' | 'one_time';
|
|
||||||
|
|
||||||
export interface OwnershipCost {
|
export interface OwnershipCost {
|
||||||
id: string;
|
id: string;
|
||||||
userId: string;
|
|
||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
documentId?: string;
|
documentId?: string | null;
|
||||||
costType: OwnershipCostType;
|
costType: OwnershipCostType;
|
||||||
description?: string;
|
|
||||||
amount: number;
|
amount: number;
|
||||||
interval: CostInterval;
|
description?: string | null;
|
||||||
startDate: string;
|
periodStart?: string | null;
|
||||||
endDate?: string;
|
periodEnd?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
@@ -27,44 +22,19 @@ export interface CreateOwnershipCostRequest {
|
|||||||
vehicleId: string;
|
vehicleId: string;
|
||||||
documentId?: string;
|
documentId?: string;
|
||||||
costType: OwnershipCostType;
|
costType: OwnershipCostType;
|
||||||
description?: string;
|
|
||||||
amount: number;
|
amount: number;
|
||||||
interval: CostInterval;
|
description?: string;
|
||||||
startDate: string;
|
periodStart?: string;
|
||||||
endDate?: string;
|
periodEnd?: string;
|
||||||
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateOwnershipCostRequest {
|
export interface UpdateOwnershipCostRequest {
|
||||||
documentId?: string | null;
|
documentId?: string | null;
|
||||||
costType?: OwnershipCostType;
|
costType?: OwnershipCostType;
|
||||||
description?: string | null;
|
|
||||||
amount?: number;
|
amount?: number;
|
||||||
interval?: CostInterval;
|
description?: string | null;
|
||||||
startDate?: string;
|
periodStart?: string | null;
|
||||||
endDate?: string | null;
|
periodEnd?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregated cost statistics
|
|
||||||
export interface OwnershipCostStats {
|
|
||||||
insuranceCosts: number;
|
|
||||||
registrationCosts: number;
|
|
||||||
taxCosts: number;
|
|
||||||
otherCosts: number;
|
|
||||||
totalCosts: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Display labels for cost types
|
|
||||||
export const COST_TYPE_LABELS: Record<OwnershipCostType, string> = {
|
|
||||||
insurance: 'Insurance',
|
|
||||||
registration: 'Registration',
|
|
||||||
tax: 'Tax',
|
|
||||||
other: 'Other',
|
|
||||||
};
|
|
||||||
|
|
||||||
// Display labels for intervals
|
|
||||||
export const INTERVAL_LABELS: Record<CostInterval, string> = {
|
|
||||||
monthly: 'Monthly',
|
|
||||||
semi_annual: 'Semi-Annual (6 months)',
|
|
||||||
annual: 'Annual',
|
|
||||||
one_time: 'One-Time',
|
|
||||||
};
|
|
||||||
|
|||||||
Reference in New Issue
Block a user