Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'io.jsonwebtoken:jjwt-api:0.11.5'

// === PDF Generation ===
implementation 'com.itextpdf:itext7-core:7.2.5'

// === Monitoring & Metrics ===
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
Expand Down
37 changes: 35 additions & 2 deletions backend/init.sql
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ USE apartment_db;
DROP TABLE IF EXISTS rental_requests;
DROP TABLE IF EXISTS maintenance_requests;
DROP TABLE IF EXISTS payments;
DROP TABLE IF EXISTS invoices;
DROP TABLE IF EXISTS leases;
DROP TABLE IF EXISTS tenants;
DROP TABLE IF EXISTS units;
Expand Down Expand Up @@ -137,27 +138,59 @@ CREATE TABLE leases (
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ============================================
-- PAYMENTS TABLE
-- INVOICES TABLE (Master invoice with INV-YYYYMMDD-XXX format)
-- ============================================
CREATE TABLE invoices (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
invoice_number VARCHAR(50) UNIQUE NOT NULL, -- Format: INV-YYYYMMDD-XXX
lease_id BIGINT NOT NULL,
invoice_date DATE NOT NULL,
due_date DATE NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
status ENUM('PENDING', 'PAID', 'OVERDUE', 'PARTIAL', 'CANCELLED') NOT NULL DEFAULT 'PENDING',
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL,
created_by_user_id BIGINT,
updated_by_user_id BIGINT,
FOREIGN KEY (lease_id) REFERENCES leases(id) ON DELETE RESTRICT,
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_invoice_number (invoice_number),
INDEX idx_lease (lease_id),
INDEX idx_invoice_date (invoice_date),
INDEX idx_due_date (due_date),
INDEX idx_status (status),
CONSTRAINT chk_invoice_total_amount CHECK (total_amount > 0),
CONSTRAINT chk_invoice_dates CHECK (due_date >= invoice_date)
Copy link

Copilot AI Nov 14, 2025

Choose a reason for hiding this comment

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

[nitpick] The constraint chk_invoice_dates uses >= which allows invoice date and due date to be the same. While this might be intentional, it's unusual in business contexts where typically a grace period is expected between invoice issuance and payment due date. Consider if this should be > instead to enforce at least one day difference.

Suggested change
CONSTRAINT chk_invoice_dates CHECK (due_date >= invoice_date)
CONSTRAINT chk_invoice_dates CHECK (due_date > invoice_date)

Copilot uses AI. Check for mistakes.
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- ============================================
-- PAYMENTS TABLE (Line items linked to invoice)
-- ============================================
CREATE TABLE payments (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
invoice_id BIGINT, -- NULL for standalone payments, NOT NULL for invoice line items
lease_id BIGINT NOT NULL,
payment_type ENUM('RENT', 'ELECTRICITY', 'WATER', 'MAINTENANCE', 'SECURITY_DEPOSIT', 'OTHER') NOT NULL DEFAULT 'RENT',
amount DECIMAL(10,2) NOT NULL,
due_date DATE NOT NULL,
paid_date DATE,
payment_method ENUM('CASH', 'BANK_TRANSFER', 'CHECK', 'ONLINE') DEFAULT 'CASH',
status ENUM('PENDING', 'PAID', 'OVERDUE', 'PARTIAL') NOT NULL DEFAULT 'PENDING',
receipt_number VARCHAR(50) UNIQUE, -- Added UNIQUE constraint
receipt_number VARCHAR(50) UNIQUE, -- Format: RENT-XXX, ELEC-XXX, WATER-XXX
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at DATETIME NULL, -- Soft delete support
created_by_user_id BIGINT, -- Audit trail
updated_by_user_id BIGINT, -- Audit trail
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE RESTRICT,
FOREIGN KEY (lease_id) REFERENCES leases(id) ON DELETE RESTRICT, -- Changed from CASCADE
FOREIGN KEY (created_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
FOREIGN KEY (updated_by_user_id) REFERENCES users(id) ON DELETE SET NULL,
INDEX idx_invoice (invoice_id),
INDEX idx_lease (lease_id),
INDEX idx_due_date (due_date),
INDEX idx_status (status),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
package apartment.example.backend.controller;

import apartment.example.backend.entity.Invoice;
import apartment.example.backend.entity.enums.PaymentType;
import apartment.example.backend.service.InvoiceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;

/**
* Invoice Controller
*
* REST API endpoints for invoice management
*/
@RestController
@RequestMapping("/invoices")
@CrossOrigin(origins = "*")
public class InvoiceController {

@Autowired
private InvoiceService invoiceService;

/**
* Create a new invoice with payment line items
*
* POST /invoices/create
*
* Request Body:
* {
* "leaseId": 1,
* "invoiceDate": "2025-11-13",
* "dueDate": "2025-11-28",
* "rentAmount": 5000,
* "electricityAmount": 800,
* "waterAmount": 200,
* "notes": "ค่าเช่าประจำเดือน พฤศจิกายน 2025"
* }
*
* Response: Invoice object with invoice number and payment line items
*/
@PostMapping("/create")
public ResponseEntity<Invoice> createInvoice(@RequestBody CreateInvoiceRequest request) {
try {
// Build payment items list
List<InvoiceService.PaymentItem> paymentItems = new ArrayList<>();

if (request.getRentAmount() != null && request.getRentAmount().compareTo(BigDecimal.ZERO) > 0) {
paymentItems.add(new InvoiceService.PaymentItem(
PaymentType.RENT,
request.getRentAmount(),
"ค่าเช่า"
));
}

if (request.getElectricityAmount() != null && request.getElectricityAmount().compareTo(BigDecimal.ZERO) > 0) {
paymentItems.add(new InvoiceService.PaymentItem(
PaymentType.ELECTRICITY,
request.getElectricityAmount(),
"ค่าไฟฟ้า"
));
}

if (request.getWaterAmount() != null && request.getWaterAmount().compareTo(BigDecimal.ZERO) > 0) {
paymentItems.add(new InvoiceService.PaymentItem(
PaymentType.WATER,
request.getWaterAmount(),
"ค่าน้ำ"
));
}

if (paymentItems.isEmpty()) {
return ResponseEntity.badRequest().build();
}

// Create invoice with payments
Invoice invoice = invoiceService.createInvoiceWithPayments(
request.getLeaseId(),
request.getInvoiceDate(),
request.getDueDate(),
paymentItems,
request.getNotes()
);

return ResponseEntity.ok(invoice);
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(null);
}
}

/**
* Get invoice by ID
*
* GET /invoices/{id}
*/
@GetMapping("/{id}")
public ResponseEntity<Invoice> getInvoiceById(@PathVariable Long id) {
try {
Invoice invoice = invoiceService.getInvoiceById(id);
return ResponseEntity.ok(invoice);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}

/**
* Get invoice by invoice number
*
* GET /invoices/number/{invoiceNumber}
*/
@GetMapping("/number/{invoiceNumber}")
public ResponseEntity<Invoice> getInvoiceByNumber(@PathVariable String invoiceNumber) {
try {
Invoice invoice = invoiceService.getInvoiceByNumber(invoiceNumber);
return ResponseEntity.ok(invoice);
} catch (RuntimeException e) {
return ResponseEntity.notFound().build();
}
}

/**
* Get all invoices for a lease
*
* GET /invoices/lease/{leaseId}
*/
@GetMapping("/lease/{leaseId}")
public ResponseEntity<List<Invoice>> getInvoicesByLeaseId(@PathVariable Long leaseId) {
List<Invoice> invoices = invoiceService.getInvoicesByLeaseId(leaseId);
return ResponseEntity.ok(invoices);
}

/**
* Create Invoice Request DTO
*/
public static class CreateInvoiceRequest {
private Long leaseId;
private LocalDate invoiceDate;
private LocalDate dueDate;
private BigDecimal rentAmount;
private BigDecimal electricityAmount;
private BigDecimal waterAmount;
private String notes;

// Getters and Setters
public Long getLeaseId() {
return leaseId;
}

public void setLeaseId(Long leaseId) {
this.leaseId = leaseId;
}

public LocalDate getInvoiceDate() {
return invoiceDate;
}

public void setInvoiceDate(LocalDate invoiceDate) {
this.invoiceDate = invoiceDate;
}

public LocalDate getDueDate() {
return dueDate;
}

public void setDueDate(LocalDate dueDate) {
this.dueDate = dueDate;
}

public BigDecimal getRentAmount() {
return rentAmount;
}

public void setRentAmount(BigDecimal rentAmount) {
this.rentAmount = rentAmount;
}

public BigDecimal getElectricityAmount() {
return electricityAmount;
}

public void setElectricityAmount(BigDecimal electricityAmount) {
this.electricityAmount = electricityAmount;
}

public BigDecimal getWaterAmount() {
return waterAmount;
}

public void setWaterAmount(BigDecimal waterAmount) {
this.waterAmount = waterAmount;
}

public String getNotes() {
return notes;
}

public void setNotes(String notes) {
this.notes = notes;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import apartment.example.backend.entity.Lease;
import apartment.example.backend.entity.enums.LeaseStatus;
import apartment.example.backend.service.LeaseService;
import apartment.example.backend.service.PdfService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

Expand All @@ -22,6 +25,7 @@
public class LeaseController {

private final LeaseService leaseService;
private final PdfService pdfService;

@GetMapping
public ResponseEntity<List<Lease>> getAllLeases() {
Expand Down Expand Up @@ -157,4 +161,42 @@ public ResponseEntity<Boolean> checkLeaseExists(@PathVariable Long id) {
boolean exists = leaseService.existsById(id);
return ResponseEntity.ok(exists);
}

/**
* Generate Lease Agreement PDF
*
* @param id Lease ID
* @return PDF file as byte array
*/
@GetMapping("/{id}/generate-pdf")
public ResponseEntity<byte[]> generateLeaseAgreementPdf(@PathVariable Long id) {
try {
log.info("Generating PDF for lease ID: {}", id);

// Get lease with tenant and unit information
Lease lease = leaseService.getLeaseById(id)
.orElseThrow(() -> new RuntimeException("Lease not found with id: " + id));

// Generate PDF
byte[] pdfBytes = pdfService.generateLeaseAgreementPdf(lease);

// Set headers for PDF download
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_PDF);
headers.setContentDispositionFormData("attachment", "lease-agreement-" + id + ".pdf");
headers.setContentLength(pdfBytes.length);

log.info("PDF generated successfully for lease ID: {}", id);
return ResponseEntity.ok()
.headers(headers)
.body(pdfBytes);

} catch (RuntimeException e) {
log.error("Error generating PDF for lease ID {}: {}", id, e.getMessage());
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
} catch (Exception e) {
log.error("Unexpected error generating PDF for lease ID {}: {}", id, e.getMessage(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
}
}
}
Loading