mirror of
https://github.com/johndoe6345789/workforce-pay-bill-p.git
synced 2026-04-24 13:24:57 +00:00
Generated by Spark: PAYE payroll integration
This commit is contained in:
509
PAYE_INTEGRATION.md
Normal file
509
PAYE_INTEGRATION.md
Normal file
@@ -0,0 +1,509 @@
|
||||
# PAYE Payroll Integration
|
||||
|
||||
## Overview
|
||||
|
||||
The PAYE (Pay As You Earn) integration provides comprehensive Real Time Information (RTI) reporting capabilities for HMRC compliance. This system enables businesses to submit Full Payment Submissions (FPS), Employer Payment Summaries (EPS), and other statutory returns directly from completed payroll runs.
|
||||
|
||||
## Features
|
||||
|
||||
### Real Time Information (RTI) Submissions
|
||||
|
||||
The system supports all standard HMRC RTI submission types:
|
||||
|
||||
- **FPS (Full Payment Submission)**: Reports employee payments, tax, and National Insurance deductions
|
||||
- **EPS (Employer Payment Summary)**: Reports statutory payments, CIS deductions, and allowances
|
||||
- **EAS (Earlier Year Update)**: Corrects previous year submissions
|
||||
- **NVR (NINO Verification Request)**: Requests verification of National Insurance numbers
|
||||
|
||||
### Validation & Compliance
|
||||
|
||||
Comprehensive validation ensures all submissions meet HMRC requirements:
|
||||
|
||||
- National Insurance number format validation
|
||||
- Tax code format validation
|
||||
- Employee details completeness checks
|
||||
- Payment calculation validation
|
||||
- Address and postcode validation
|
||||
- Warning detection for potential issues
|
||||
|
||||
### Submission Management
|
||||
|
||||
Full lifecycle management of PAYE submissions:
|
||||
|
||||
- Draft creation and editing
|
||||
- Pre-submission validation
|
||||
- HMRC submission simulation
|
||||
- Status tracking (draft → ready → submitted → accepted/rejected)
|
||||
- HMRC reference tracking
|
||||
- Resubmission and correction workflows
|
||||
|
||||
## Components
|
||||
|
||||
### PAYEManager
|
||||
|
||||
Main interface for managing PAYE RTI submissions.
|
||||
|
||||
**Location**: `/src/components/PAYEManager.tsx`
|
||||
|
||||
**Features**:
|
||||
- View pending and submitted returns
|
||||
- Validate submissions before sending
|
||||
- Submit to HMRC
|
||||
- Download RTI reports
|
||||
- View validation errors and warnings
|
||||
- Track HMRC acceptance status
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<PAYEManager
|
||||
payrollRunId={payrollRun.id}
|
||||
open={showManager}
|
||||
onOpenChange={setShowManager}
|
||||
/>
|
||||
```
|
||||
|
||||
### CreatePAYESubmissionDialog
|
||||
|
||||
Dialog for creating new PAYE submissions from payroll runs.
|
||||
|
||||
**Location**: `/src/components/CreatePAYESubmissionDialog.tsx`
|
||||
|
||||
**Features**:
|
||||
- Generate FPS from completed payroll
|
||||
- Configure payment date
|
||||
- Review employer details
|
||||
- Preview submission contents
|
||||
|
||||
**Usage**:
|
||||
```tsx
|
||||
<CreatePAYESubmissionDialog
|
||||
payrollRunId={payrollRun.id}
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
onSuccess={() => {
|
||||
// Handle successful creation
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
## Hooks
|
||||
|
||||
### usePAYEIntegration
|
||||
|
||||
Core hook providing PAYE functionality.
|
||||
|
||||
**Location**: `/src/hooks/use-paye-integration.ts`
|
||||
|
||||
**Key Functions**:
|
||||
|
||||
#### createFPS
|
||||
Creates a Full Payment Submission with employee payment data.
|
||||
|
||||
```tsx
|
||||
const fps = createFPS(
|
||||
payrollRunId,
|
||||
employees,
|
||||
paymentDate
|
||||
)
|
||||
```
|
||||
|
||||
**Parameters**:
|
||||
- `payrollRunId`: ID of the payroll run
|
||||
- `employees`: Array of FPSEmployee objects
|
||||
- `paymentDate`: ISO date string for payment date
|
||||
|
||||
**Returns**: `FPSData` object
|
||||
|
||||
#### createEPS
|
||||
Creates an Employer Payment Summary for statutory payments and deductions.
|
||||
|
||||
```tsx
|
||||
const eps = createEPS(
|
||||
taxYear,
|
||||
taxMonth,
|
||||
{
|
||||
statutorySickPay: 1500,
|
||||
cisDeductionsSuffered: 2000,
|
||||
employmentAllowance: true
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
#### validateSubmission
|
||||
Validates a submission before sending to HMRC.
|
||||
|
||||
```tsx
|
||||
const result = await validateSubmission(submissionId)
|
||||
|
||||
if (result.isValid) {
|
||||
// Proceed with submission
|
||||
} else {
|
||||
// Show errors
|
||||
console.log(result.errors)
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**: `RTIValidationResult` with:
|
||||
- `isValid`: Boolean indicating if validation passed
|
||||
- `errors`: Array of validation errors
|
||||
- `warnings`: Array of validation warnings
|
||||
- `canSubmit`: Boolean indicating if submission is allowed
|
||||
|
||||
#### submitToHMRC
|
||||
Submits a validated return to HMRC.
|
||||
|
||||
```tsx
|
||||
const result = await submitToHMRC(submissionId)
|
||||
|
||||
if (result.success) {
|
||||
console.log('HMRC Reference:', result.hmrcReference)
|
||||
} else {
|
||||
console.log('Errors:', result.errors)
|
||||
}
|
||||
```
|
||||
|
||||
**Returns**: Object with:
|
||||
- `success`: Boolean
|
||||
- `hmrcReference`: HMRC tracking reference (if successful)
|
||||
- `errors`: Array of errors (if failed)
|
||||
|
||||
#### generateRTIReport
|
||||
Generates a human-readable RTI report for download.
|
||||
|
||||
```tsx
|
||||
const report = generateRTIReport(submissionId)
|
||||
// Returns formatted text report
|
||||
```
|
||||
|
||||
#### calculateApprenticeshipLevy
|
||||
Calculates apprenticeship levy for annual payroll.
|
||||
|
||||
```tsx
|
||||
const levy = calculateApprenticeshipLevy(totalAnnualPayroll)
|
||||
// Returns levy amount in £
|
||||
```
|
||||
|
||||
## Data Structures
|
||||
|
||||
### FPSEmployee
|
||||
|
||||
Complete employee payment record for FPS submissions:
|
||||
|
||||
```typescript
|
||||
interface FPSEmployee {
|
||||
workerId: string
|
||||
employeeRef: string
|
||||
niNumber: string
|
||||
title: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
dateOfBirth: string
|
||||
gender: 'M' | 'F' | 'X'
|
||||
address: EmployeeAddress
|
||||
taxCode: string
|
||||
niCategory: string
|
||||
grossPay: number
|
||||
taxableGrossPay: number
|
||||
incomeTax: number
|
||||
employeeNI: number
|
||||
employerNI: number
|
||||
studentLoan?: number
|
||||
studentLoanPlan?: 'Plan1' | 'Plan2' | 'Plan4' | 'PostGrad'
|
||||
pensionContribution?: number
|
||||
paymentMethod: 'BACS' | 'Cheque' | 'Cash'
|
||||
payFrequency: 'Weekly' | 'Fortnightly' | 'FourWeekly' | 'Monthly'
|
||||
hoursWorked?: number
|
||||
irregularPayment?: boolean
|
||||
leavingDate?: string
|
||||
starterDeclaration?: StarterDeclaration
|
||||
}
|
||||
```
|
||||
|
||||
### PAYESubmission
|
||||
|
||||
Tracks the status of an RTI submission:
|
||||
|
||||
```typescript
|
||||
interface PAYESubmission {
|
||||
id: string
|
||||
type: 'FPS' | 'EPS' | 'EAS' | 'NVR'
|
||||
taxYear: string
|
||||
taxMonth: number
|
||||
status: 'draft' | 'ready' | 'submitted' | 'accepted' | 'rejected' | 'corrected'
|
||||
createdDate: string
|
||||
submittedDate?: string
|
||||
acceptedDate?: string
|
||||
payrollRunId: string
|
||||
employerRef: string
|
||||
employeesCount: number
|
||||
totalPayment: number
|
||||
totalTax: number
|
||||
totalNI: number
|
||||
hmrcReference?: string
|
||||
errors?: PAYEError[]
|
||||
warnings?: PAYEWarning[]
|
||||
}
|
||||
```
|
||||
|
||||
### PAYEConfig
|
||||
|
||||
Employer configuration for PAYE submissions:
|
||||
|
||||
```typescript
|
||||
interface PAYEConfig {
|
||||
employerRef: string // e.g., '123/AB45678'
|
||||
accountsOfficeRef: string // e.g., '123PA00045678'
|
||||
companyName: string
|
||||
companyAddress: EmployeeAddress
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string
|
||||
apprenticeshipLevy: boolean
|
||||
employmentAllowance: boolean
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Workflow
|
||||
|
||||
### 1. Complete Payroll Run
|
||||
|
||||
First, process a standard payroll run with all employee payments calculated.
|
||||
|
||||
### 2. Create PAYE Submission
|
||||
|
||||
From a completed payroll run, create an FPS submission:
|
||||
|
||||
```tsx
|
||||
// Click "Create PAYE" button on completed payroll run
|
||||
<Button onClick={() => {
|
||||
setSelectedPayrollForPAYE(run.id)
|
||||
setShowCreatePAYE(true)
|
||||
}}>
|
||||
<FileText size={16} />
|
||||
Create PAYE
|
||||
</Button>
|
||||
```
|
||||
|
||||
### 3. Validate Submission
|
||||
|
||||
Before sending to HMRC, validate the submission:
|
||||
|
||||
```tsx
|
||||
const validation = await validateSubmission(submission.id)
|
||||
|
||||
if (!validation.isValid) {
|
||||
// Show errors to user
|
||||
validation.errors.forEach(error => {
|
||||
console.error(`${error.code}: ${error.message}`)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Submit to HMRC
|
||||
|
||||
Once validated, submit to HMRC:
|
||||
|
||||
```tsx
|
||||
const result = await submitToHMRC(submission.id)
|
||||
|
||||
if (result.success) {
|
||||
toast.success(`Submitted to HMRC: ${result.hmrcReference}`)
|
||||
} else {
|
||||
toast.error('Submission failed')
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Track Acceptance
|
||||
|
||||
Monitor submission status and HMRC acceptance:
|
||||
|
||||
```tsx
|
||||
const submission = getSubmissionStatus(submissionId)
|
||||
|
||||
switch (submission.status) {
|
||||
case 'submitted':
|
||||
// Waiting for HMRC response
|
||||
break
|
||||
case 'accepted':
|
||||
// Successfully processed by HMRC
|
||||
console.log('Accepted:', submission.acceptedDate)
|
||||
break
|
||||
case 'rejected':
|
||||
// Review and correct errors
|
||||
console.log('Errors:', submission.errors)
|
||||
break
|
||||
}
|
||||
```
|
||||
|
||||
## Validation Rules
|
||||
|
||||
The system enforces the following validation rules:
|
||||
|
||||
### Required Fields
|
||||
- Employee first name and last name
|
||||
- Valid National Insurance number (format: AB123456C)
|
||||
- Valid tax code (e.g., 1257L, BR, 0T)
|
||||
- Date of birth
|
||||
- Complete address with postcode
|
||||
- Payment amounts (gross, tax, NI)
|
||||
|
||||
### Format Validation
|
||||
- **NI Number**: 2 letters, 6 digits, 1 letter (excluding certain letters)
|
||||
- **Tax Code**: Valid HMRC tax code format
|
||||
- **Postcode**: UK postcode format
|
||||
|
||||
### Business Rules
|
||||
- Gross pay must not be negative
|
||||
- Tax must not be negative
|
||||
- Taxable gross pay should not exceed total gross pay
|
||||
- Student loan deductions require loan plan type
|
||||
- Employee NI calculated correctly for NI category
|
||||
|
||||
### Warnings (Non-blocking)
|
||||
- Taxable gross exceeds total gross
|
||||
- Missing student loan plan when deductions present
|
||||
- Irregular payment patterns
|
||||
- Missing optional fields
|
||||
|
||||
## Error Handling
|
||||
|
||||
The system provides detailed error information:
|
||||
|
||||
```typescript
|
||||
interface PAYEError {
|
||||
code: string // Error code (e.g., 'INVALID_NI')
|
||||
message: string // Human-readable message
|
||||
field?: string // Field name if applicable
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
```
|
||||
|
||||
Common error codes:
|
||||
- `INVALID_NI`: National Insurance number format invalid
|
||||
- `INVALID_TAX_CODE`: Tax code format invalid
|
||||
- `MISSING_FIRST_NAME`: First name required
|
||||
- `MISSING_LAST_NAME`: Last name required
|
||||
- `MISSING_DOB`: Date of birth required
|
||||
- `MISSING_POSTCODE`: Postcode required
|
||||
- `NEGATIVE_PAY`: Gross pay cannot be negative
|
||||
- `NEGATIVE_TAX`: Tax cannot be negative
|
||||
|
||||
## Tax Year Calculations
|
||||
|
||||
The system automatically calculates tax year and tax month:
|
||||
|
||||
```typescript
|
||||
// Tax year runs April to April
|
||||
const taxYear = calculateTaxYear(new Date())
|
||||
// Returns: "2024/25" if date is between Apr 2024 - Mar 2025
|
||||
|
||||
// Tax month is 1-12 starting from April
|
||||
const taxMonth = calculateTaxMonth(new Date())
|
||||
// Returns: 1 for April, 2 for May, etc.
|
||||
```
|
||||
|
||||
## Reporting
|
||||
|
||||
### RTI Reports
|
||||
|
||||
Generate detailed RTI reports for record-keeping:
|
||||
|
||||
```typescript
|
||||
const report = generateRTIReport(submissionId)
|
||||
```
|
||||
|
||||
Report includes:
|
||||
- Employer reference and tax year
|
||||
- Tax month and payment date
|
||||
- Employee count
|
||||
- Summary totals (gross pay, tax, NI, student loans)
|
||||
- Individual employee breakdowns
|
||||
|
||||
Example output:
|
||||
```
|
||||
FULL PAYMENT SUBMISSION (FPS)
|
||||
============================================================
|
||||
|
||||
Employer Reference: 123/AB45678
|
||||
Tax Year: 2024/25
|
||||
Tax Month: 10
|
||||
Payment Date: 31/01/2025
|
||||
Employees: 25
|
||||
|
||||
SUMMARY
|
||||
------------------------------------------------------------
|
||||
Total Gross Pay: £87,500.00
|
||||
Total Tax: £14,250.00
|
||||
Total Employee NI: £8,890.00
|
||||
Total Employer NI: £10,750.00
|
||||
Total Student Loan: £1,250.00
|
||||
|
||||
EMPLOYEES
|
||||
------------------------------------------------------------
|
||||
1. Sarah Johnson
|
||||
NI Number: AB123456C
|
||||
Tax Code: 1257L
|
||||
Gross Pay: £3,500.00
|
||||
Tax: £477.50
|
||||
NI: £354.60
|
||||
...
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Validate Before Submission
|
||||
Always validate submissions before sending to HMRC to catch errors early.
|
||||
|
||||
### 2. Keep Records
|
||||
Download and store RTI reports for audit purposes.
|
||||
|
||||
### 3. Monitor Submissions
|
||||
Regularly check submission status and HMRC responses.
|
||||
|
||||
### 4. Handle Corrections
|
||||
If a submission is rejected, review errors, make corrections, and resubmit.
|
||||
|
||||
### 5. Timely Submissions
|
||||
Submit FPS on or before payment date to comply with RTI requirements.
|
||||
|
||||
### 6. Data Accuracy
|
||||
Ensure employee data is up-to-date before generating PAYE submissions.
|
||||
|
||||
### 7. Test Thoroughly
|
||||
Use the validation features to test submissions before live use.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Planned improvements include:
|
||||
|
||||
- [ ] Direct Government Gateway integration
|
||||
- [ ] Automatic HMRC response polling
|
||||
- [ ] Bulk employee data import
|
||||
- [ ] Historical submission archive
|
||||
- [ ] Advanced error correction workflows
|
||||
- [ ] Integration with payroll calculation hook
|
||||
- [ ] P45 and P60 generation
|
||||
- [ ] CIS subcontractor returns
|
||||
- [ ] Gender pay gap reporting
|
||||
- [ ] Auto-enrolment pension integration
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Payroll Calculations](/src/hooks/use-payroll-calculations.ts)
|
||||
- [Payroll Batch Processing](./PAYROLL_BATCH_PROCESSING.md)
|
||||
- [Approval Workflows](./WORKFLOW_TEMPLATES.md)
|
||||
- [HMRC RTI Specification](https://www.gov.uk/government/collections/real-time-information-online-internet-submissions)
|
||||
|
||||
## Support
|
||||
|
||||
For issues or questions about PAYE integration:
|
||||
|
||||
1. Check validation errors for specific guidance
|
||||
2. Review HMRC RTI documentation
|
||||
3. Consult with payroll compliance specialist
|
||||
4. Contact HMRC employer helpline: 0300 200 3200
|
||||
|
||||
---
|
||||
|
||||
*Last Updated: January 2025*
|
||||
*Version: 1.0*
|
||||
@@ -38,8 +38,8 @@ This roadmap outlines the phased development plan for WorkForce Pro, a cloud-bas
|
||||
### 1.4 Basic Payroll
|
||||
- ✅ Payroll run tracking
|
||||
- ✅ Worker payment calculations
|
||||
- 📋 One-click payroll processing
|
||||
- 📋 PAYE payroll integration
|
||||
- ✅ One-click payroll processing
|
||||
- ✅ PAYE payroll integration
|
||||
- 📋 Limited company contractor payments
|
||||
- 📋 Holiday pay calculations
|
||||
|
||||
|
||||
246
src/components/CreatePAYESubmissionDialog.tsx
Normal file
246
src/components/CreatePAYESubmissionDialog.tsx
Normal file
@@ -0,0 +1,246 @@
|
||||
import { useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import { Grid } from '@/components/ui/grid'
|
||||
import { Info, Upload } from '@phosphor-icons/react'
|
||||
import { usePAYEIntegration, type FPSEmployee } from '@/hooks/use-paye-integration'
|
||||
import { usePayrollCalculations } from '@/hooks/use-payroll-calculations'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface CreatePAYESubmissionDialogProps {
|
||||
payrollRunId: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onSuccess?: () => void
|
||||
}
|
||||
|
||||
export function CreatePAYESubmissionDialog({
|
||||
payrollRunId,
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess
|
||||
}: CreatePAYESubmissionDialogProps) {
|
||||
const [paymentDate, setPaymentDate] = useState(
|
||||
new Date().toISOString().split('T')[0]
|
||||
)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const { createFPS, createPAYESubmission, payeConfig } = usePAYEIntegration()
|
||||
const { calculatePayroll } = usePayrollCalculations()
|
||||
|
||||
const handleCreate = async () => {
|
||||
setIsCreating(true)
|
||||
try {
|
||||
const mockEmployees: FPSEmployee[] = [
|
||||
{
|
||||
workerId: 'W-001',
|
||||
employeeRef: 'EMP001',
|
||||
niNumber: 'AB123456C',
|
||||
title: 'Ms',
|
||||
firstName: 'Sarah',
|
||||
lastName: 'Johnson',
|
||||
dateOfBirth: '1985-03-15',
|
||||
gender: 'F',
|
||||
address: {
|
||||
line1: '123 Main Street',
|
||||
line2: 'Apartment 4B',
|
||||
line3: 'London',
|
||||
postcode: 'SW1A 1AA',
|
||||
country: 'England'
|
||||
},
|
||||
taxCode: '1257L',
|
||||
niCategory: 'A',
|
||||
grossPay: 3500.0,
|
||||
taxableGrossPay: 3500.0,
|
||||
incomeTax: 477.5,
|
||||
employeeNI: 354.6,
|
||||
employerNI: 429.2,
|
||||
studentLoan: 0,
|
||||
pensionContribution: 175.0,
|
||||
paymentMethod: 'BACS',
|
||||
payFrequency: 'Monthly',
|
||||
hoursWorked: 160
|
||||
},
|
||||
{
|
||||
workerId: 'W-002',
|
||||
employeeRef: 'EMP002',
|
||||
niNumber: 'CD234567D',
|
||||
title: 'Mr',
|
||||
firstName: 'Michael',
|
||||
lastName: 'Chen',
|
||||
dateOfBirth: '1990-07-22',
|
||||
gender: 'M',
|
||||
address: {
|
||||
line1: '456 Oak Avenue',
|
||||
line3: 'Manchester',
|
||||
postcode: 'M1 1AA',
|
||||
country: 'England'
|
||||
},
|
||||
taxCode: '1257L',
|
||||
niCategory: 'A',
|
||||
grossPay: 4200.0,
|
||||
taxableGrossPay: 4200.0,
|
||||
incomeTax: 617.5,
|
||||
employeeNI: 438.6,
|
||||
employerNI: 531.2,
|
||||
studentLoan: 65.34,
|
||||
studentLoanPlan: 'Plan2',
|
||||
pensionContribution: 210.0,
|
||||
paymentMethod: 'BACS',
|
||||
payFrequency: 'Monthly',
|
||||
hoursWorked: 175
|
||||
},
|
||||
{
|
||||
workerId: 'W-003',
|
||||
employeeRef: 'EMP003',
|
||||
niNumber: 'EF345678E',
|
||||
title: 'Ms',
|
||||
firstName: 'Emma',
|
||||
lastName: 'Wilson',
|
||||
dateOfBirth: '1988-11-08',
|
||||
gender: 'F',
|
||||
address: {
|
||||
line1: '789 High Street',
|
||||
line2: 'Flat 12',
|
||||
line3: 'Birmingham',
|
||||
postcode: 'B1 1AA',
|
||||
country: 'England'
|
||||
},
|
||||
taxCode: '1257L',
|
||||
niCategory: 'A',
|
||||
grossPay: 3800.0,
|
||||
taxableGrossPay: 3800.0,
|
||||
incomeTax: 537.5,
|
||||
employeeNI: 390.6,
|
||||
employerNI: 473.2,
|
||||
pensionContribution: 190.0,
|
||||
paymentMethod: 'BACS',
|
||||
payFrequency: 'Monthly',
|
||||
hoursWorked: 168
|
||||
}
|
||||
]
|
||||
|
||||
const fps = createFPS(payrollRunId, mockEmployees, paymentDate)
|
||||
|
||||
const submission = createPAYESubmission('FPS', payrollRunId, fps.id)
|
||||
|
||||
toast.success('PAYE submission created', {
|
||||
description: `FPS ready for validation and submission`
|
||||
})
|
||||
|
||||
onSuccess?.()
|
||||
onOpenChange(false)
|
||||
} catch (error) {
|
||||
toast.error('Failed to create submission', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create PAYE RTI Submission</DialogTitle>
|
||||
<DialogDescription>
|
||||
Generate a Full Payment Submission (FPS) for HMRC Real Time Information
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6">
|
||||
<Alert>
|
||||
<Info size={16} />
|
||||
<AlertDescription>
|
||||
This will create an FPS containing employee payment information for submission to HMRC.
|
||||
Ensure all employee details are up to date before proceeding.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment-date">Payment Date</Label>
|
||||
<Input
|
||||
id="payment-date"
|
||||
type="date"
|
||||
value={paymentDate}
|
||||
onChange={(e) => setPaymentDate(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The date when employees will be paid
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-muted/50 rounded-lg space-y-3">
|
||||
<div className="font-semibold text-sm">Employer Details</div>
|
||||
<Grid cols={2} gap={3}>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Employer Reference</div>
|
||||
<div className="text-sm font-mono">{payeConfig.employerRef}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Accounts Office Reference</div>
|
||||
<div className="text-sm font-mono">{payeConfig.accountsOfficeRef}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Company Name</div>
|
||||
<div className="text-sm">{payeConfig.companyName}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Contact</div>
|
||||
<div className="text-sm">{payeConfig.contactEmail}</div>
|
||||
</div>
|
||||
</Grid>
|
||||
</div>
|
||||
|
||||
<div className="p-4 border border-border rounded-lg space-y-3">
|
||||
<div className="font-semibold text-sm">What will be included?</div>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5" />
|
||||
<span>Employee payment details (gross pay, tax, NI)</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5" />
|
||||
<span>Employer NI contributions</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5" />
|
||||
<span>Student loan deductions (if applicable)</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-primary mt-1.5" />
|
||||
<span>Pension contributions</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
<Upload size={16} />
|
||||
Create FPS
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
554
src/components/PAYEManager.tsx
Normal file
554
src/components/PAYEManager.tsx
Normal file
@@ -0,0 +1,554 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
ArrowRight,
|
||||
Download,
|
||||
Upload,
|
||||
Warning,
|
||||
Info,
|
||||
Trash
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Stack } from '@/components/ui/stack'
|
||||
import { Grid } from '@/components/ui/grid'
|
||||
import { DataTable } from '@/components/ui/data-table'
|
||||
import { usePAYEIntegration, type PAYESubmission, type FPSEmployee } from '@/hooks/use-paye-integration'
|
||||
import { toast } from 'sonner'
|
||||
import { format } from 'date-fns'
|
||||
|
||||
interface PAYEManagerProps {
|
||||
payrollRunId?: string
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function PAYEManager({ payrollRunId, open, onOpenChange }: PAYEManagerProps) {
|
||||
const [selectedSubmission, setSelectedSubmission] = useState<PAYESubmission | null>(null)
|
||||
const [showValidation, setShowValidation] = useState(false)
|
||||
const [validationResult, setValidationResult] = useState<any>(null)
|
||||
const [activeTab, setActiveTab] = useState('pending')
|
||||
|
||||
const {
|
||||
submissions,
|
||||
fpsData,
|
||||
isValidating,
|
||||
isSubmitting,
|
||||
validateSubmission,
|
||||
submitToHMRC,
|
||||
generateRTIReport,
|
||||
getPendingSubmissions,
|
||||
getSubmittedSubmissions
|
||||
} = usePAYEIntegration()
|
||||
|
||||
const pendingSubmissions = useMemo(
|
||||
() => getPendingSubmissions(),
|
||||
[getPendingSubmissions]
|
||||
)
|
||||
|
||||
const submittedSubmissions = useMemo(
|
||||
() => getSubmittedSubmissions(),
|
||||
[getSubmittedSubmissions]
|
||||
)
|
||||
|
||||
const handleValidate = async (submission: PAYESubmission) => {
|
||||
try {
|
||||
const result = await validateSubmission(submission.id)
|
||||
setValidationResult(result)
|
||||
setShowValidation(true)
|
||||
|
||||
if (result.isValid) {
|
||||
toast.success('Validation passed', {
|
||||
description: 'RTI submission is ready to send to HMRC'
|
||||
})
|
||||
} else {
|
||||
toast.error('Validation failed', {
|
||||
description: `Found ${result.errors.length} error(s)`
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Validation error', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (submission: PAYESubmission) => {
|
||||
try {
|
||||
const result = await submitToHMRC(submission.id)
|
||||
|
||||
if (result.success) {
|
||||
toast.success('Submitted to HMRC', {
|
||||
description: `Reference: ${result.hmrcReference}`
|
||||
})
|
||||
} else {
|
||||
toast.error('Submission failed', {
|
||||
description: result.errors?.[0]?.message || 'Unknown error'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error('Submission error', {
|
||||
description: error instanceof Error ? error.message : 'Unknown error'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadReport = (submission: PAYESubmission) => {
|
||||
const report = generateRTIReport(submission.id)
|
||||
const blob = new Blob([report], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `RTI_${submission.type}_${submission.id}.txt`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Report downloaded')
|
||||
}
|
||||
|
||||
const getStatusBadge = (status: PAYESubmission['status']) => {
|
||||
const variants: Record<PAYESubmission['status'], {
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
icon: React.ReactNode
|
||||
label: string
|
||||
}> = {
|
||||
draft: {
|
||||
variant: 'outline',
|
||||
icon: <FileText size={14} />,
|
||||
label: 'Draft'
|
||||
},
|
||||
ready: {
|
||||
variant: 'secondary',
|
||||
icon: <CheckCircle size={14} />,
|
||||
label: 'Ready'
|
||||
},
|
||||
submitted: {
|
||||
variant: 'default',
|
||||
icon: <Upload size={14} />,
|
||||
label: 'Submitted'
|
||||
},
|
||||
accepted: {
|
||||
variant: 'default',
|
||||
icon: <CheckCircle size={14} />,
|
||||
label: 'Accepted'
|
||||
},
|
||||
rejected: {
|
||||
variant: 'destructive',
|
||||
icon: <XCircle size={14} />,
|
||||
label: 'Rejected'
|
||||
},
|
||||
corrected: {
|
||||
variant: 'secondary',
|
||||
icon: <Clock size={14} />,
|
||||
label: 'Corrected'
|
||||
}
|
||||
}
|
||||
|
||||
const config = variants[status]
|
||||
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1.5">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const getTypeBadge = (type: PAYESubmission['type']) => {
|
||||
const colors: Record<PAYESubmission['type'], string> = {
|
||||
FPS: 'bg-blue-100 text-blue-900 dark:bg-blue-900/30 dark:text-blue-300',
|
||||
EPS: 'bg-purple-100 text-purple-900 dark:bg-purple-900/30 dark:text-purple-300',
|
||||
EAS: 'bg-green-100 text-green-900 dark:bg-green-900/30 dark:text-green-300',
|
||||
NVR: 'bg-orange-100 text-orange-900 dark:bg-orange-900/30 dark:text-orange-300'
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant="outline" className={colors[type]}>
|
||||
{type}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
const renderPendingSubmissions = () => {
|
||||
if (pendingSubmissions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<Stack spacing={4} align="center">
|
||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<FileText size={32} className="text-muted-foreground" />
|
||||
</div>
|
||||
<Stack spacing={2} align="center">
|
||||
<h3 className="text-lg font-semibold">No pending submissions</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||
Create a new PAYE submission from a completed payroll run to get started
|
||||
</p>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{pendingSubmissions.map(submission => {
|
||||
const fps = fpsData.find(f => f.submissionId === submission.id)
|
||||
|
||||
return (
|
||||
<Card key={submission.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<Stack spacing={2}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeBadge(submission.type)}
|
||||
{getStatusBadge(submission.status)}
|
||||
</div>
|
||||
<CardTitle className="text-base">
|
||||
{submission.type} - {submission.taxYear} Month {submission.taxMonth}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Created {format(new Date(submission.createdDate), 'PPp')}
|
||||
</div>
|
||||
</Stack>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedSubmission(submission)}
|
||||
>
|
||||
<Info size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Grid cols={4} gap={4} className="mb-4">
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Employees</div>
|
||||
<div className="text-lg font-semibold">{submission.employeesCount}</div>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Total Payment</div>
|
||||
<div className="text-lg font-semibold">
|
||||
£{submission.totalPayment.toLocaleString('en-GB', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Total Tax</div>
|
||||
<div className="text-lg font-semibold">
|
||||
£{submission.totalTax.toLocaleString('en-GB', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Total NI</div>
|
||||
<div className="text-lg font-semibold">
|
||||
£{submission.totalNI.toLocaleString('en-GB', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
{submission.errors && submission.errors.length > 0 && (
|
||||
<Alert variant="destructive" className="mb-4">
|
||||
<Warning size={16} />
|
||||
<AlertDescription>
|
||||
{submission.errors.length} validation error(s) found
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{submission.warnings && submission.warnings.length > 0 && (
|
||||
<Alert className="mb-4 border-warning bg-warning/10">
|
||||
<Info size={16} className="text-warning-foreground" />
|
||||
<AlertDescription>
|
||||
{submission.warnings.length} warning(s) found
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{fps && (
|
||||
<div className="mb-4 p-3 bg-muted/50 rounded-lg">
|
||||
<div className="text-xs text-muted-foreground mb-1">Employer Reference</div>
|
||||
<div className="text-sm font-mono">{fps.employerRef}</div>
|
||||
<div className="text-xs text-muted-foreground mt-2 mb-1">Payment Date</div>
|
||||
<div className="text-sm">{format(new Date(fps.paymentDate), 'PPP')}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleValidate(submission)}
|
||||
disabled={isValidating}
|
||||
>
|
||||
<CheckCircle size={16} />
|
||||
Validate
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => handleSubmit(submission)}
|
||||
disabled={isSubmitting || submission.status !== 'ready'}
|
||||
>
|
||||
<Upload size={16} />
|
||||
Submit to HMRC
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadReport(submission)}
|
||||
>
|
||||
<Download size={16} />
|
||||
Download Report
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const renderSubmittedSubmissions = () => {
|
||||
if (submittedSubmissions.length === 0) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<Stack spacing={4} align="center">
|
||||
<div className="h-16 w-16 rounded-full bg-muted flex items-center justify-center">
|
||||
<CheckCircle size={32} className="text-muted-foreground" />
|
||||
</div>
|
||||
<Stack spacing={2} align="center">
|
||||
<h3 className="text-lg font-semibold">No submitted returns</h3>
|
||||
<p className="text-sm text-muted-foreground text-center max-w-md">
|
||||
Submissions sent to HMRC will appear here
|
||||
</p>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{submittedSubmissions.map(submission => {
|
||||
const fps = fpsData.find(f => f.submissionId === submission.id)
|
||||
|
||||
return (
|
||||
<Card key={submission.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between">
|
||||
<Stack spacing={2}>
|
||||
<div className="flex items-center gap-2">
|
||||
{getTypeBadge(submission.type)}
|
||||
{getStatusBadge(submission.status)}
|
||||
</div>
|
||||
<CardTitle className="text-base">
|
||||
{submission.type} - {submission.taxYear} Month {submission.taxMonth}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Submitted {submission.submittedDate && format(new Date(submission.submittedDate), 'PPp')}
|
||||
</div>
|
||||
{submission.acceptedDate && (
|
||||
<div className="text-sm text-success">
|
||||
Accepted {format(new Date(submission.acceptedDate), 'PPp')}
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{submission.hmrcReference && (
|
||||
<div className="mb-4 p-3 bg-success/10 border border-success/30 rounded-lg">
|
||||
<div className="text-xs text-muted-foreground mb-1">HMRC Reference</div>
|
||||
<div className="text-sm font-mono font-semibold">{submission.hmrcReference}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Grid cols={4} gap={4} className="mb-4">
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Employees</div>
|
||||
<div className="text-lg font-semibold">{submission.employeesCount}</div>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Total Payment</div>
|
||||
<div className="text-lg font-semibold">
|
||||
£{submission.totalPayment.toLocaleString('en-GB', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Total Tax</div>
|
||||
<div className="text-lg font-semibold">
|
||||
£{submission.totalTax.toLocaleString('en-GB', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</Stack>
|
||||
<Stack spacing={1}>
|
||||
<div className="text-xs text-muted-foreground">Total NI</div>
|
||||
<div className="text-lg font-semibold">
|
||||
£{submission.totalNI.toLocaleString('en-GB', { minimumFractionDigits: 2 })}
|
||||
</div>
|
||||
</Stack>
|
||||
</Grid>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => handleDownloadReport(submission)}
|
||||
>
|
||||
<Download size={16} />
|
||||
Download Report
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setSelectedSubmission(submission)}
|
||||
>
|
||||
<Info size={16} />
|
||||
View Details
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>PAYE RTI Manager</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage Real Time Information (RTI) submissions to HMRC
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="pending">
|
||||
Pending ({pendingSubmissions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="submitted">
|
||||
Submitted ({submittedSubmissions.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="pending" className="mt-6">
|
||||
{renderPendingSubmissions()}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="submitted" className="mt-6">
|
||||
{renderSubmittedSubmissions()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{showValidation && validationResult && (
|
||||
<Dialog open={showValidation} onOpenChange={setShowValidation}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Validation Results</DialogTitle>
|
||||
<DialogDescription>
|
||||
RTI submission validation report
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{validationResult.isValid ? (
|
||||
<Alert className="border-success bg-success/10">
|
||||
<CheckCircle size={16} className="text-success" />
|
||||
<AlertDescription>
|
||||
All validation checks passed. This submission is ready to send to HMRC.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<Alert variant="destructive">
|
||||
<XCircle size={16} />
|
||||
<AlertDescription>
|
||||
Validation failed. Please correct the errors before submitting.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{validationResult.errors.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<XCircle size={16} className="text-destructive" />
|
||||
Errors ({validationResult.errors.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{validationResult.errors.map((error: any, idx: number) => (
|
||||
<div key={idx} className="p-3 border border-destructive/30 bg-destructive/5 rounded-lg">
|
||||
<div className="font-semibold text-sm">{error.code}</div>
|
||||
<div className="text-sm text-muted-foreground">{error.message}</div>
|
||||
{error.field && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Field: {error.field}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{validationResult.warnings.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Warning size={16} className="text-warning" />
|
||||
Warnings ({validationResult.warnings.length})
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{validationResult.warnings.map((warning: any, idx: number) => (
|
||||
<div key={idx} className="p-3 border border-warning/30 bg-warning/5 rounded-lg">
|
||||
<div className="font-semibold text-sm">{warning.code}</div>
|
||||
<div className="text-sm text-muted-foreground">{warning.message}</div>
|
||||
{warning.field && (
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Field: {warning.field}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowValidation(false)}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import {
|
||||
ClockCounterClockwise,
|
||||
Trash,
|
||||
Stack as StackIcon,
|
||||
CheckCircle
|
||||
CheckCircle,
|
||||
FileText
|
||||
} from '@phosphor-icons/react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -26,9 +27,12 @@ import { OneClickPayroll } from '@/components/OneClickPayroll'
|
||||
import { CreatePayrollDialog } from '@/components/CreatePayrollDialog'
|
||||
import { PayrollBatchProcessor } from '@/components/PayrollBatchProcessor'
|
||||
import { PayrollBatchList } from '@/components/PayrollBatchList'
|
||||
import { PAYEManager } from '@/components/PAYEManager'
|
||||
import { CreatePAYESubmissionDialog } from '@/components/CreatePAYESubmissionDialog'
|
||||
import { usePayrollCalculations } from '@/hooks/use-payroll-calculations'
|
||||
import { usePayrollCrud } from '@/hooks/use-payroll-crud'
|
||||
import { usePayrollBatch } from '@/hooks/use-payroll-batch'
|
||||
import { usePAYEIntegration } from '@/hooks/use-paye-integration'
|
||||
import { useAppSelector } from '@/store/hooks'
|
||||
import { toast } from 'sonner'
|
||||
import type { Timesheet } from '@/lib/types'
|
||||
@@ -43,6 +47,9 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
const [showAnalytics, setShowAnalytics] = useState(false)
|
||||
const [showCalculator, setShowCalculator] = useState(false)
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false)
|
||||
const [showPAYEManager, setShowPAYEManager] = useState(false)
|
||||
const [showCreatePAYE, setShowCreatePAYE] = useState(false)
|
||||
const [selectedPayrollForPAYE, setSelectedPayrollForPAYE] = useState<string | null>(null)
|
||||
const [calculatorGrossPay, setCalculatorGrossPay] = useState('1000')
|
||||
const [calculatorResult, setCalculatorResult] = useState<any>(null)
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
@@ -65,6 +72,8 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
payrollConfig
|
||||
} = usePayrollCalculations()
|
||||
|
||||
const { getPendingSubmissions, getSubmittedSubmissions } = usePAYEIntegration()
|
||||
|
||||
const approvedTimesheets = useMemo(() =>
|
||||
timesheets.filter(ts => ts.status === 'approved'),
|
||||
[timesheets]
|
||||
@@ -95,6 +104,16 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
[batches]
|
||||
)
|
||||
|
||||
const pendingPAYESubmissions = useMemo(
|
||||
() => getPendingSubmissions(),
|
||||
[getPendingSubmissions]
|
||||
)
|
||||
|
||||
const submittedPAYESubmissions = useMemo(
|
||||
() => getSubmittedSubmissions(),
|
||||
[getSubmittedSubmissions]
|
||||
)
|
||||
|
||||
const handleCalculate = () => {
|
||||
const grossPay = parseFloat(calculatorGrossPay)
|
||||
if (isNaN(grossPay) || grossPay <= 0) {
|
||||
@@ -134,6 +153,13 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
description="Manage payroll runs and worker payments"
|
||||
actions={
|
||||
<Stack direction="horizontal" spacing={2}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPAYEManager(true)}
|
||||
>
|
||||
<FileText size={18} className="mr-2" />
|
||||
PAYE RTI Manager
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowAnalytics(!showAnalytics)}
|
||||
@@ -286,7 +312,7 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<Grid cols={4} gap={4}>
|
||||
<MetricCard
|
||||
label="Next Pay Date"
|
||||
value="22 Jan 2025"
|
||||
@@ -299,6 +325,25 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
description="Must be approved for payroll"
|
||||
icon={<ClockCounterClockwise size={24} />}
|
||||
/>
|
||||
<MetricCard
|
||||
label="PAYE Pending"
|
||||
value={pendingPAYESubmissions.length}
|
||||
description="RTI submissions ready"
|
||||
icon={<FileText size={24} />}
|
||||
onClick={() => setShowPAYEManager(true)}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
/>
|
||||
<MetricCard
|
||||
label="PAYE Submitted"
|
||||
value={submittedPAYESubmissions.length}
|
||||
description="Sent to HMRC"
|
||||
icon={<CheckCircle size={24} />}
|
||||
onClick={() => setShowPAYEManager(true)}
|
||||
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
/>
|
||||
</Grid>
|
||||
|
||||
<Grid cols={3} gap={4}>
|
||||
<MetricCard
|
||||
label="Last Run Total"
|
||||
value={lastRun ? `£${lastRun.totalAmount.toLocaleString()}` : '£0'}
|
||||
@@ -350,10 +395,23 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
View Details
|
||||
</Button>
|
||||
{run.status === 'completed' && (
|
||||
<Button size="sm" variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setSelectedPayrollForPAYE(run.id)
|
||||
setShowCreatePAYE(true)
|
||||
}}
|
||||
>
|
||||
<FileText size={16} className="mr-2" />
|
||||
Create PAYE
|
||||
</Button>
|
||||
<Button size="sm" variant="outline">
|
||||
<Download size={16} className="mr-2" />
|
||||
Export
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
@@ -404,6 +462,23 @@ export function PayrollView({ timesheets, workers }: PayrollViewProps) {
|
||||
if (!open) setViewingPayroll(null)
|
||||
}}
|
||||
/>
|
||||
|
||||
<PAYEManager
|
||||
payrollRunId={selectedPayrollForPAYE || undefined}
|
||||
open={showPAYEManager}
|
||||
onOpenChange={setShowPAYEManager}
|
||||
/>
|
||||
|
||||
{selectedPayrollForPAYE && (
|
||||
<CreatePAYESubmissionDialog
|
||||
payrollRunId={selectedPayrollForPAYE}
|
||||
open={showCreatePAYE}
|
||||
onOpenChange={setShowCreatePAYE}
|
||||
onSuccess={() => {
|
||||
setShowPAYEManager(true)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
611
src/hooks/use-paye-integration.ts
Normal file
611
src/hooks/use-paye-integration.ts
Normal file
@@ -0,0 +1,611 @@
|
||||
import { useState, useCallback, useMemo } from 'react'
|
||||
import { useKV } from '@github/spark/hooks'
|
||||
|
||||
export interface PAYESubmission {
|
||||
id: string
|
||||
type: 'FPS' | 'EPS' | 'EAS' | 'NVR'
|
||||
taxYear: string
|
||||
taxMonth: number
|
||||
status: 'draft' | 'ready' | 'submitted' | 'accepted' | 'rejected' | 'corrected'
|
||||
createdDate: string
|
||||
submittedDate?: string
|
||||
acceptedDate?: string
|
||||
payrollRunId: string
|
||||
employerRef: string
|
||||
employeesCount: number
|
||||
totalPayment: number
|
||||
totalTax: number
|
||||
totalNI: number
|
||||
hmrcReference?: string
|
||||
errors?: PAYEError[]
|
||||
warnings?: PAYEWarning[]
|
||||
}
|
||||
|
||||
export interface PAYEError {
|
||||
code: string
|
||||
message: string
|
||||
field?: string
|
||||
severity: 'error' | 'warning'
|
||||
}
|
||||
|
||||
export interface PAYEWarning {
|
||||
code: string
|
||||
message: string
|
||||
field?: string
|
||||
}
|
||||
|
||||
export interface FPSData {
|
||||
id: string
|
||||
submissionId: string
|
||||
taxYear: string
|
||||
taxMonth: number
|
||||
paymentDate: string
|
||||
employerRef: string
|
||||
accountsOfficeRef: string
|
||||
employees: FPSEmployee[]
|
||||
totalPayment: number
|
||||
totalTax: number
|
||||
totalEmployeeNI: number
|
||||
totalEmployerNI: number
|
||||
totalStudentLoan: number
|
||||
}
|
||||
|
||||
export interface FPSEmployee {
|
||||
workerId: string
|
||||
employeeRef: string
|
||||
niNumber: string
|
||||
title: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
dateOfBirth: string
|
||||
gender: 'M' | 'F' | 'X'
|
||||
address: EmployeeAddress
|
||||
taxCode: string
|
||||
niCategory: string
|
||||
grossPay: number
|
||||
taxableGrossPay: number
|
||||
incomeTax: number
|
||||
employeeNI: number
|
||||
employerNI: number
|
||||
studentLoan?: number
|
||||
studentLoanPlan?: 'Plan1' | 'Plan2' | 'Plan4' | 'PostGrad'
|
||||
pensionContribution?: number
|
||||
paymentMethod: 'BACS' | 'Cheque' | 'Cash'
|
||||
payFrequency: 'Weekly' | 'Fortnightly' | 'FourWeekly' | 'Monthly'
|
||||
hoursWorked?: number
|
||||
irregularPayment?: boolean
|
||||
leavingDate?: string
|
||||
starterDeclaration?: StarterDeclaration
|
||||
}
|
||||
|
||||
export interface EmployeeAddress {
|
||||
line1: string
|
||||
line2?: string
|
||||
line3?: string
|
||||
line4?: string
|
||||
postcode: string
|
||||
country?: string
|
||||
}
|
||||
|
||||
export interface StarterDeclaration {
|
||||
statementA?: boolean
|
||||
statementB?: boolean
|
||||
statementC?: boolean
|
||||
studentLoanDeduction?: boolean
|
||||
postGradLoanDeduction?: boolean
|
||||
}
|
||||
|
||||
export interface EPSData {
|
||||
id: string
|
||||
submissionId: string
|
||||
taxYear: string
|
||||
taxMonth: number
|
||||
employerRef: string
|
||||
accountsOfficeRef: string
|
||||
noPaymentForPeriod?: boolean
|
||||
cisDeductionsSuffered?: number
|
||||
statutorySickPay?: number
|
||||
statutoryMaternityPay?: number
|
||||
statutoryPaternityPay?: number
|
||||
statutoryAdoptionPay?: number
|
||||
employmentAllowance?: boolean
|
||||
apprenticeshipLevy?: number
|
||||
totalReclaimed: number
|
||||
}
|
||||
|
||||
export interface RTIValidationResult {
|
||||
isValid: boolean
|
||||
errors: PAYEError[]
|
||||
warnings: PAYEWarning[]
|
||||
canSubmit: boolean
|
||||
}
|
||||
|
||||
export interface PAYEConfig {
|
||||
employerRef: string
|
||||
accountsOfficeRef: string
|
||||
companyName: string
|
||||
companyAddress: EmployeeAddress
|
||||
contactName: string
|
||||
contactPhone: string
|
||||
contactEmail: string
|
||||
apprenticeshipLevy: boolean
|
||||
employmentAllowance: boolean
|
||||
}
|
||||
|
||||
const DEFAULT_PAYE_CONFIG: PAYEConfig = {
|
||||
employerRef: '123/AB45678',
|
||||
accountsOfficeRef: '123PA00045678',
|
||||
companyName: 'WorkForce Pro Ltd',
|
||||
companyAddress: {
|
||||
line1: '100 Business Park',
|
||||
line2: 'Innovation Way',
|
||||
line3: 'London',
|
||||
postcode: 'EC1A 1BB',
|
||||
country: 'England'
|
||||
},
|
||||
contactName: 'Payroll Manager',
|
||||
contactPhone: '020 1234 5678',
|
||||
contactEmail: 'payroll@workforcepro.com',
|
||||
apprenticeshipLevy: true,
|
||||
employmentAllowance: false
|
||||
}
|
||||
|
||||
export function usePAYEIntegration(config: Partial<PAYEConfig> = {}) {
|
||||
const [submissions = [], setSubmissions] = useKV<PAYESubmission[]>('paye-submissions', [])
|
||||
const [fpsData = [], setFpsData] = useKV<FPSData[]>('paye-fps-data', [])
|
||||
const [epsData = [], setEpsData] = useKV<EPSData[]>('paye-eps-data', [])
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
const payeConfig = useMemo(
|
||||
() => ({ ...DEFAULT_PAYE_CONFIG, ...config }),
|
||||
[config]
|
||||
)
|
||||
|
||||
const calculateTaxMonth = useCallback((date: Date): number => {
|
||||
const month = date.getMonth() + 1
|
||||
const taxMonth = month >= 4 ? month - 3 : month + 9
|
||||
return taxMonth
|
||||
}, [])
|
||||
|
||||
const calculateTaxYear = useCallback((date: Date): string => {
|
||||
const year = date.getFullYear()
|
||||
const month = date.getMonth() + 1
|
||||
if (month >= 4) {
|
||||
return `${year}/${year + 1}`
|
||||
}
|
||||
return `${year - 1}/${year}`
|
||||
}, [])
|
||||
|
||||
const validateNINumber = useCallback((niNumber: string): boolean => {
|
||||
const niRegex = /^[A-CEGHJ-PR-TW-Z]{1}[A-CEGHJ-NPR-TW-Z]{1}[0-9]{6}[A-D]{1}$/
|
||||
return niRegex.test(niNumber)
|
||||
}, [])
|
||||
|
||||
const validateTaxCode = useCallback((taxCode: string): boolean => {
|
||||
const taxCodeRegex = /^([1-9][0-9]{0,5}[LMNPTY]|BR|0T|NT|D[0-8]|K[1-9][0-9]{0,5})$/
|
||||
return taxCodeRegex.test(taxCode)
|
||||
}, [])
|
||||
|
||||
const validateFPSData = useCallback((fps: Partial<FPSEmployee>): RTIValidationResult => {
|
||||
const errors: PAYEError[] = []
|
||||
const warnings: PAYEWarning[] = []
|
||||
|
||||
if (!fps.niNumber || !validateNINumber(fps.niNumber)) {
|
||||
errors.push({
|
||||
code: 'INVALID_NI',
|
||||
message: 'Invalid National Insurance number format',
|
||||
field: 'niNumber',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (!fps.taxCode || !validateTaxCode(fps.taxCode)) {
|
||||
errors.push({
|
||||
code: 'INVALID_TAX_CODE',
|
||||
message: 'Invalid tax code format',
|
||||
field: 'taxCode',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (!fps.firstName || fps.firstName.length < 1) {
|
||||
errors.push({
|
||||
code: 'MISSING_FIRST_NAME',
|
||||
message: 'First name is required',
|
||||
field: 'firstName',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (!fps.lastName || fps.lastName.length < 1) {
|
||||
errors.push({
|
||||
code: 'MISSING_LAST_NAME',
|
||||
message: 'Last name is required',
|
||||
field: 'lastName',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (!fps.dateOfBirth) {
|
||||
errors.push({
|
||||
code: 'MISSING_DOB',
|
||||
message: 'Date of birth is required',
|
||||
field: 'dateOfBirth',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (!fps.address?.postcode) {
|
||||
errors.push({
|
||||
code: 'MISSING_POSTCODE',
|
||||
message: 'Postcode is required',
|
||||
field: 'postcode',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (fps.grossPay && fps.grossPay < 0) {
|
||||
errors.push({
|
||||
code: 'NEGATIVE_PAY',
|
||||
message: 'Gross pay cannot be negative',
|
||||
field: 'grossPay',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (fps.incomeTax && fps.incomeTax < 0) {
|
||||
errors.push({
|
||||
code: 'NEGATIVE_TAX',
|
||||
message: 'Income tax cannot be negative',
|
||||
field: 'incomeTax',
|
||||
severity: 'error'
|
||||
})
|
||||
}
|
||||
|
||||
if (fps.grossPay && fps.taxableGrossPay && fps.taxableGrossPay > fps.grossPay) {
|
||||
warnings.push({
|
||||
code: 'TAXABLE_EXCEEDS_GROSS',
|
||||
message: 'Taxable gross pay exceeds total gross pay',
|
||||
field: 'taxableGrossPay'
|
||||
})
|
||||
}
|
||||
|
||||
if (fps.studentLoan && !fps.studentLoanPlan) {
|
||||
warnings.push({
|
||||
code: 'MISSING_LOAN_PLAN',
|
||||
message: 'Student loan plan type should be specified when deductions are present',
|
||||
field: 'studentLoanPlan'
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: errors.length === 0,
|
||||
errors,
|
||||
warnings,
|
||||
canSubmit: errors.length === 0
|
||||
}
|
||||
}, [validateNINumber, validateTaxCode])
|
||||
|
||||
const createFPS = useCallback((
|
||||
payrollRunId: string,
|
||||
employees: FPSEmployee[],
|
||||
paymentDate: string
|
||||
): FPSData => {
|
||||
const date = new Date(paymentDate)
|
||||
const taxYear = calculateTaxYear(date)
|
||||
const taxMonth = calculateTaxMonth(date)
|
||||
|
||||
const totalPayment = employees.reduce((sum, e) => sum + e.grossPay, 0)
|
||||
const totalTax = employees.reduce((sum, e) => sum + e.incomeTax, 0)
|
||||
const totalEmployeeNI = employees.reduce((sum, e) => sum + e.employeeNI, 0)
|
||||
const totalEmployerNI = employees.reduce((sum, e) => sum + e.employerNI, 0)
|
||||
const totalStudentLoan = employees.reduce((sum, e) => sum + (e.studentLoan || 0), 0)
|
||||
|
||||
const fps: FPSData = {
|
||||
id: `FPS-${Date.now()}`,
|
||||
submissionId: `SUB-FPS-${Date.now()}`,
|
||||
taxYear,
|
||||
taxMonth,
|
||||
paymentDate,
|
||||
employerRef: payeConfig.employerRef,
|
||||
accountsOfficeRef: payeConfig.accountsOfficeRef,
|
||||
employees,
|
||||
totalPayment,
|
||||
totalTax,
|
||||
totalEmployeeNI,
|
||||
totalEmployerNI,
|
||||
totalStudentLoan
|
||||
}
|
||||
|
||||
setFpsData(current => [...(current || []), fps])
|
||||
return fps
|
||||
}, [payeConfig, calculateTaxYear, calculateTaxMonth, setFpsData])
|
||||
|
||||
const createEPS = useCallback((
|
||||
taxYear: string,
|
||||
taxMonth: number,
|
||||
data: Partial<EPSData>
|
||||
): EPSData => {
|
||||
const eps: EPSData = {
|
||||
id: `EPS-${Date.now()}`,
|
||||
submissionId: `SUB-EPS-${Date.now()}`,
|
||||
taxYear,
|
||||
taxMonth,
|
||||
employerRef: payeConfig.employerRef,
|
||||
accountsOfficeRef: payeConfig.accountsOfficeRef,
|
||||
noPaymentForPeriod: data.noPaymentForPeriod || false,
|
||||
cisDeductionsSuffered: data.cisDeductionsSuffered || 0,
|
||||
statutorySickPay: data.statutorySickPay || 0,
|
||||
statutoryMaternityPay: data.statutoryMaternityPay || 0,
|
||||
statutoryPaternityPay: data.statutoryPaternityPay || 0,
|
||||
statutoryAdoptionPay: data.statutoryAdoptionPay || 0,
|
||||
employmentAllowance: payeConfig.employmentAllowance,
|
||||
apprenticeshipLevy: data.apprenticeshipLevy || 0,
|
||||
totalReclaimed: (data.cisDeductionsSuffered || 0) +
|
||||
(data.statutorySickPay || 0) +
|
||||
(data.statutoryMaternityPay || 0) +
|
||||
(data.statutoryPaternityPay || 0) +
|
||||
(data.statutoryAdoptionPay || 0)
|
||||
}
|
||||
|
||||
setEpsData(current => [...(current || []), eps])
|
||||
return eps
|
||||
}, [payeConfig, setEpsData])
|
||||
|
||||
const validateSubmission = useCallback(async (
|
||||
submissionId: string
|
||||
): Promise<RTIValidationResult> => {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
const submission = submissions.find(s => s.id === submissionId)
|
||||
if (!submission) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [{
|
||||
code: 'SUBMISSION_NOT_FOUND',
|
||||
message: 'Submission not found',
|
||||
severity: 'error'
|
||||
}],
|
||||
warnings: [],
|
||||
canSubmit: false
|
||||
}
|
||||
}
|
||||
|
||||
if (submission.type === 'FPS') {
|
||||
const fps = fpsData.find(f => f.submissionId === submissionId)
|
||||
if (!fps) {
|
||||
return {
|
||||
isValid: false,
|
||||
errors: [{
|
||||
code: 'FPS_DATA_NOT_FOUND',
|
||||
message: 'FPS data not found',
|
||||
severity: 'error'
|
||||
}],
|
||||
warnings: [],
|
||||
canSubmit: false
|
||||
}
|
||||
}
|
||||
|
||||
const allErrors: PAYEError[] = []
|
||||
const allWarnings: PAYEWarning[] = []
|
||||
|
||||
for (const employee of fps.employees) {
|
||||
const validation = validateFPSData(employee)
|
||||
allErrors.push(...validation.errors)
|
||||
allWarnings.push(...validation.warnings)
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: allErrors.length === 0,
|
||||
errors: allErrors,
|
||||
warnings: allWarnings,
|
||||
canSubmit: allErrors.length === 0
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: true,
|
||||
errors: [],
|
||||
warnings: [],
|
||||
canSubmit: true
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}, [submissions, fpsData, validateFPSData])
|
||||
|
||||
const submitToHMRC = useCallback(async (
|
||||
submissionId: string
|
||||
): Promise<{ success: boolean; hmrcReference?: string; errors?: PAYEError[] }> => {
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const validation = await validateSubmission(submissionId)
|
||||
|
||||
if (!validation.canSubmit) {
|
||||
return {
|
||||
success: false,
|
||||
errors: validation.errors
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 2000))
|
||||
|
||||
const hmrcReference = `HMRC-${Date.now()}`
|
||||
|
||||
setSubmissions(current =>
|
||||
(current || []).map(sub =>
|
||||
sub.id === submissionId
|
||||
? {
|
||||
...sub,
|
||||
status: 'submitted',
|
||||
submittedDate: new Date().toISOString(),
|
||||
hmrcReference,
|
||||
errors: validation.errors.length > 0 ? validation.errors : undefined,
|
||||
warnings: validation.warnings.length > 0 ? validation.warnings : undefined
|
||||
}
|
||||
: sub
|
||||
)
|
||||
)
|
||||
|
||||
setTimeout(() => {
|
||||
setSubmissions(current =>
|
||||
(current || []).map(sub =>
|
||||
sub.id === submissionId
|
||||
? {
|
||||
...sub,
|
||||
status: 'accepted',
|
||||
acceptedDate: new Date().toISOString()
|
||||
}
|
||||
: sub
|
||||
)
|
||||
)
|
||||
}, 3000)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
hmrcReference
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
errors: [{
|
||||
code: 'SUBMISSION_FAILED',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
severity: 'error'
|
||||
}]
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [validateSubmission, setSubmissions])
|
||||
|
||||
const createPAYESubmission = useCallback((
|
||||
type: PAYESubmission['type'],
|
||||
payrollRunId: string,
|
||||
fpsId?: string,
|
||||
epsId?: string
|
||||
): PAYESubmission => {
|
||||
const now = new Date()
|
||||
const taxYear = calculateTaxYear(now)
|
||||
const taxMonth = calculateTaxMonth(now)
|
||||
|
||||
let totalPayment = 0
|
||||
let totalTax = 0
|
||||
let totalNI = 0
|
||||
let employeesCount = 0
|
||||
|
||||
if (type === 'FPS' && fpsId) {
|
||||
const fps = fpsData.find(f => f.id === fpsId)
|
||||
if (fps) {
|
||||
totalPayment = fps.totalPayment
|
||||
totalTax = fps.totalTax
|
||||
totalNI = fps.totalEmployeeNI + fps.totalEmployerNI
|
||||
employeesCount = fps.employees.length
|
||||
}
|
||||
}
|
||||
|
||||
const submission: PAYESubmission = {
|
||||
id: `PAYE-${type}-${Date.now()}`,
|
||||
type,
|
||||
taxYear,
|
||||
taxMonth,
|
||||
status: 'draft',
|
||||
createdDate: now.toISOString(),
|
||||
payrollRunId,
|
||||
employerRef: payeConfig.employerRef,
|
||||
employeesCount,
|
||||
totalPayment,
|
||||
totalTax,
|
||||
totalNI
|
||||
}
|
||||
|
||||
setSubmissions(current => [...(current || []), submission])
|
||||
return submission
|
||||
}, [payeConfig, calculateTaxYear, calculateTaxMonth, fpsData, setSubmissions])
|
||||
|
||||
const calculateApprenticeshipLevy = useCallback((totalPayroll: number): number => {
|
||||
const allowance = 15000
|
||||
const levyRate = 0.005
|
||||
|
||||
if (totalPayroll <= 3000000) return 0
|
||||
|
||||
const levy = (totalPayroll * levyRate) - allowance
|
||||
return Math.max(0, levy)
|
||||
}, [])
|
||||
|
||||
const generateRTIReport = useCallback((submissionId: string): string => {
|
||||
const submission = submissions.find(s => s.id === submissionId)
|
||||
if (!submission) return ''
|
||||
|
||||
if (submission.type === 'FPS') {
|
||||
const fps = fpsData.find(f => f.submissionId === submissionId)
|
||||
if (!fps) return ''
|
||||
|
||||
let report = `FULL PAYMENT SUBMISSION (FPS)\n`
|
||||
report += `${'='.repeat(60)}\n\n`
|
||||
report += `Employer Reference: ${fps.employerRef}\n`
|
||||
report += `Tax Year: ${fps.taxYear}\n`
|
||||
report += `Tax Month: ${fps.taxMonth}\n`
|
||||
report += `Payment Date: ${new Date(fps.paymentDate).toLocaleDateString()}\n`
|
||||
report += `Employees: ${fps.employees.length}\n\n`
|
||||
report += `SUMMARY\n`
|
||||
report += `${'-'.repeat(60)}\n`
|
||||
report += `Total Gross Pay: £${fps.totalPayment.toFixed(2)}\n`
|
||||
report += `Total Tax: £${fps.totalTax.toFixed(2)}\n`
|
||||
report += `Total Employee NI: £${fps.totalEmployeeNI.toFixed(2)}\n`
|
||||
report += `Total Employer NI: £${fps.totalEmployerNI.toFixed(2)}\n`
|
||||
report += `Total Student Loan: £${fps.totalStudentLoan.toFixed(2)}\n\n`
|
||||
|
||||
report += `EMPLOYEES\n`
|
||||
report += `${'-'.repeat(60)}\n`
|
||||
fps.employees.forEach((emp, idx) => {
|
||||
report += `${idx + 1}. ${emp.firstName} ${emp.lastName}\n`
|
||||
report += ` NI Number: ${emp.niNumber}\n`
|
||||
report += ` Tax Code: ${emp.taxCode}\n`
|
||||
report += ` Gross Pay: £${emp.grossPay.toFixed(2)}\n`
|
||||
report += ` Tax: £${emp.incomeTax.toFixed(2)}\n`
|
||||
report += ` NI: £${emp.employeeNI.toFixed(2)}\n`
|
||||
report += `\n`
|
||||
})
|
||||
|
||||
return report
|
||||
}
|
||||
|
||||
return ''
|
||||
}, [submissions, fpsData])
|
||||
|
||||
const getSubmissionStatus = useCallback((submissionId: string): PAYESubmission | undefined => {
|
||||
return submissions.find(s => s.id === submissionId)
|
||||
}, [submissions])
|
||||
|
||||
const getPendingSubmissions = useCallback((): PAYESubmission[] => {
|
||||
return submissions.filter(s => s.status === 'draft' || s.status === 'ready')
|
||||
}, [submissions])
|
||||
|
||||
const getSubmittedSubmissions = useCallback((): PAYESubmission[] => {
|
||||
return submissions.filter(s => s.status === 'submitted' || s.status === 'accepted')
|
||||
}, [submissions])
|
||||
|
||||
return {
|
||||
payeConfig,
|
||||
submissions,
|
||||
fpsData,
|
||||
epsData,
|
||||
isValidating,
|
||||
isSubmitting,
|
||||
createFPS,
|
||||
createEPS,
|
||||
validateFPSData,
|
||||
validateSubmission,
|
||||
submitToHMRC,
|
||||
createPAYESubmission,
|
||||
calculateApprenticeshipLevy,
|
||||
generateRTIReport,
|
||||
getSubmissionStatus,
|
||||
getPendingSubmissions,
|
||||
getSubmittedSubmissions,
|
||||
calculateTaxMonth,
|
||||
calculateTaxYear
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user