From a8ae869feaaa1da043753a668ba83759918a5a5a Mon Sep 17 00:00:00 2001 From: Pipatpong Primna <114746788+Pipatq@users.noreply.github.com> Date: Thu, 13 Nov 2025 22:22:22 +0700 Subject: [PATCH] Add SQL for Invoice --- backend/build.gradle | 3 + backend/init.sql | 37 +++- .../backend/controller/InvoiceController.java | 206 ++++++++++++++++++ .../backend/controller/LeaseController.java | 42 ++++ .../example/backend/entity/Invoice.java | 116 ++++++++++ .../example/backend/entity/Payment.java | 7 + .../backend/repository/InvoiceRepository.java | 47 ++++ .../backend/service/InvoiceService.java | 196 +++++++++++++++++ .../backend/service/PaymentService.java | 2 +- .../example/backend/service/PdfService.java | 194 +++++++++++++++++ frontend/src/api/services/invoices.service.js | 91 ++++++++ .../src/pages/admin/unit/send_bill/page.jsx | 65 +++--- 12 files changed, 966 insertions(+), 40 deletions(-) create mode 100644 backend/src/main/java/apartment/example/backend/controller/InvoiceController.java create mode 100644 backend/src/main/java/apartment/example/backend/entity/Invoice.java create mode 100644 backend/src/main/java/apartment/example/backend/repository/InvoiceRepository.java create mode 100644 backend/src/main/java/apartment/example/backend/service/InvoiceService.java create mode 100644 backend/src/main/java/apartment/example/backend/service/PdfService.java create mode 100644 frontend/src/api/services/invoices.service.js diff --git a/backend/build.gradle b/backend/build.gradle index 3c3c05e..b61284a 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -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' diff --git a/backend/init.sql b/backend/init.sql index 37cc30a..ac856f9 100644 --- a/backend/init.sql +++ b/backend/init.sql @@ -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; @@ -137,10 +138,40 @@ 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) +) 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, @@ -148,16 +179,18 @@ CREATE TABLE payments ( 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), diff --git a/backend/src/main/java/apartment/example/backend/controller/InvoiceController.java b/backend/src/main/java/apartment/example/backend/controller/InvoiceController.java new file mode 100644 index 0000000..8c34c83 --- /dev/null +++ b/backend/src/main/java/apartment/example/backend/controller/InvoiceController.java @@ -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 createInvoice(@RequestBody CreateInvoiceRequest request) { + try { + // Build payment items list + List 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 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 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> getInvoicesByLeaseId(@PathVariable Long leaseId) { + List 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; + } + } +} diff --git a/backend/src/main/java/apartment/example/backend/controller/LeaseController.java b/backend/src/main/java/apartment/example/backend/controller/LeaseController.java index 1877282..145adfe 100644 --- a/backend/src/main/java/apartment/example/backend/controller/LeaseController.java +++ b/backend/src/main/java/apartment/example/backend/controller/LeaseController.java @@ -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.*; @@ -22,6 +25,7 @@ public class LeaseController { private final LeaseService leaseService; + private final PdfService pdfService; @GetMapping public ResponseEntity> getAllLeases() { @@ -157,4 +161,42 @@ public ResponseEntity 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 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(); + } + } } \ No newline at end of file diff --git a/backend/src/main/java/apartment/example/backend/entity/Invoice.java b/backend/src/main/java/apartment/example/backend/entity/Invoice.java new file mode 100644 index 0000000..bdad627 --- /dev/null +++ b/backend/src/main/java/apartment/example/backend/entity/Invoice.java @@ -0,0 +1,116 @@ +package apartment.example.backend.entity; + +import com.fasterxml.jackson.annotation.JsonManagedReference; +import jakarta.persistence.*; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +/** + * Invoice Entity - Master invoice containing multiple payment line items + * + * Represents a billing document with format: INV-YYYYMMDD-XXX + * Example: INV-20251113-1 + * + * Each invoice can contain multiple payments (RENT, ELECTRICITY, WATER, etc.) + * which act as line items under the master invoice number. + */ +@Entity +@Table(name = "invoices", indexes = { + @Index(name = "idx_invoice_number", columnList = "invoice_number"), + @Index(name = "idx_lease", columnList = "lease_id"), + @Index(name = "idx_invoice_date", columnList = "invoice_date"), + @Index(name = "idx_due_date", columnList = "due_date"), + @Index(name = "idx_status", columnList = "status") +}) +@Data +public class Invoice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "invoice_number", unique = true, nullable = false, length = 50) + private String invoiceNumber; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "lease_id", nullable = false) + @ToString.Exclude + @EqualsAndHashCode.Exclude + private Lease lease; + + @Column(name = "invoice_date", nullable = false) + private LocalDate invoiceDate; + + @Column(name = "due_date", nullable = false) + private LocalDate dueDate; + + @Column(name = "total_amount", nullable = false, precision = 10, scale = 2) + private BigDecimal totalAmount; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false) + private InvoiceStatus status = InvoiceStatus.PENDING; + + @Column(name = "notes", columnDefinition = "TEXT") + private String notes; + + @OneToMany(mappedBy = "invoice", cascade = CascadeType.ALL, orphanRemoval = true) + @JsonManagedReference("invoice-payments") + @ToString.Exclude + @EqualsAndHashCode.Exclude + private List payments = new ArrayList<>(); + + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Column(name = "updated_at") + private LocalDateTime updatedAt; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + @Column(name = "created_by_user_id") + private Long createdByUserId; + + @Column(name = "updated_by_user_id") + private Long updatedByUserId; + + // Lifecycle callbacks + @PrePersist + protected void onCreate() { + this.createdAt = LocalDateTime.now(); + this.updatedAt = LocalDateTime.now(); + } + + @PreUpdate + protected void onUpdate() { + this.updatedAt = LocalDateTime.now(); + } + + // Helper methods + public void addPayment(Payment payment) { + payments.add(payment); + payment.setInvoice(this); + } + + public void removePayment(Payment payment) { + payments.remove(payment); + payment.setInvoice(null); + } + + // Enum for Invoice Status + public enum InvoiceStatus { + PENDING, + PAID, + OVERDUE, + PARTIAL, + CANCELLED + } +} diff --git a/backend/src/main/java/apartment/example/backend/entity/Payment.java b/backend/src/main/java/apartment/example/backend/entity/Payment.java index 5b3474a..e605441 100644 --- a/backend/src/main/java/apartment/example/backend/entity/Payment.java +++ b/backend/src/main/java/apartment/example/backend/entity/Payment.java @@ -34,6 +34,13 @@ public class Payment { @EqualsAndHashCode.Exclude private Lease lease; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "invoice_id") + @JsonBackReference("invoice-payments") + @ToString.Exclude + @EqualsAndHashCode.Exclude + private Invoice invoice; + @Enumerated(EnumType.STRING) @Column(name = "payment_type", nullable = false) private PaymentType paymentType; diff --git a/backend/src/main/java/apartment/example/backend/repository/InvoiceRepository.java b/backend/src/main/java/apartment/example/backend/repository/InvoiceRepository.java new file mode 100644 index 0000000..b7e0c79 --- /dev/null +++ b/backend/src/main/java/apartment/example/backend/repository/InvoiceRepository.java @@ -0,0 +1,47 @@ +package apartment.example.backend.repository; + +import apartment.example.backend.entity.Invoice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +/** + * Invoice Repository + * + * Handles database operations for Invoice entities + */ +@Repository +public interface InvoiceRepository extends JpaRepository { + + /** + * Find invoice by invoice number + */ + Optional findByInvoiceNumber(String invoiceNumber); + + /** + * Find all invoices for a specific lease + */ + List findByLeaseId(Long leaseId); + + /** + * Count invoices by invoice date (for generating unique invoice numbers) + */ + @Query("SELECT COUNT(i) FROM Invoice i WHERE i.invoiceDate = :invoiceDate") + long countByInvoiceDate(@Param("invoiceDate") LocalDate invoiceDate); + + /** + * Find latest invoice number for a specific date (for generating next invoice number) + */ + @Query("SELECT i FROM Invoice i WHERE i.invoiceDate = :invoiceDate ORDER BY i.createdAt DESC") + List findByInvoiceDateOrderByCreatedAtDesc(@Param("invoiceDate") LocalDate invoiceDate); + + /** + * Check if invoice number already exists + */ + boolean existsByInvoiceNumber(String invoiceNumber); +} diff --git a/backend/src/main/java/apartment/example/backend/service/InvoiceService.java b/backend/src/main/java/apartment/example/backend/service/InvoiceService.java new file mode 100644 index 0000000..f7bd14e --- /dev/null +++ b/backend/src/main/java/apartment/example/backend/service/InvoiceService.java @@ -0,0 +1,196 @@ +package apartment.example.backend.service; + +import apartment.example.backend.entity.Invoice; +import apartment.example.backend.entity.Lease; +import apartment.example.backend.entity.Payment; +import apartment.example.backend.entity.enums.PaymentStatus; +import apartment.example.backend.entity.enums.PaymentType; +import apartment.example.backend.repository.InvoiceRepository; +import apartment.example.backend.repository.LeaseRepository; +import apartment.example.backend.repository.PaymentRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +/** + * Invoice Service + * + * Business logic for creating and managing invoices with payment line items + */ +@Service +public class InvoiceService { + + @Autowired + private InvoiceRepository invoiceRepository; + + @Autowired + private LeaseRepository leaseRepository; + + @Autowired + private PaymentRepository paymentRepository; + + @Autowired + private PaymentService paymentService; + + /** + * Create an invoice with multiple payment line items + * + * @param leaseId Lease ID + * @param invoiceDate Invoice date + * @param dueDate Due date + * @param paymentItems List of payment items (type and amount) + * @param notes Optional notes + * @return Created invoice with payments + */ + @Transactional + public Invoice createInvoiceWithPayments( + Long leaseId, + LocalDate invoiceDate, + LocalDate dueDate, + List paymentItems, + String notes) { + + // Validate lease exists + Lease lease = leaseRepository.findById(leaseId) + .orElseThrow(() -> new RuntimeException("Lease not found with id: " + leaseId)); + + // Calculate total amount + BigDecimal totalAmount = paymentItems.stream() + .map(PaymentItem::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // Generate invoice number: INV-YYYYMMDD-XXX + String invoiceNumber = generateInvoiceNumber(invoiceDate); + + // Create invoice + Invoice invoice = new Invoice(); + invoice.setInvoiceNumber(invoiceNumber); + invoice.setLease(lease); + invoice.setInvoiceDate(invoiceDate); + invoice.setDueDate(dueDate); + invoice.setTotalAmount(totalAmount); + invoice.setStatus(Invoice.InvoiceStatus.PENDING); + invoice.setNotes(notes); + + // Save invoice first to get ID + invoice = invoiceRepository.save(invoice); + + // Create payment line items + for (PaymentItem item : paymentItems) { + Payment payment = new Payment(); + payment.setInvoice(invoice); + payment.setLease(lease); + payment.setPaymentType(item.getPaymentType()); + payment.setAmount(item.getAmount()); + payment.setDueDate(dueDate); + payment.setStatus(PaymentStatus.PENDING); + payment.setNotes(item.getDescription()); + + // Generate receipt number for each payment (RENT-XXX, ELEC-XXX, etc.) + String receiptNumber = paymentService.generateReceiptNumber(item.getPaymentType()); + payment.setReceiptNumber(receiptNumber); + + invoice.addPayment(payment); + } + + // Save invoice with payments + return invoiceRepository.save(invoice); + } + + /** + * Generate unique invoice number in format: INV-YYYYMMDD-XXX + * + * Example: INV-20251113-1, INV-20251113-2, etc. + */ + private String generateInvoiceNumber(LocalDate invoiceDate) { + // Format date as YYYYMMDD + String dateStr = invoiceDate.format(DateTimeFormatter.ofPattern("yyyyMMdd")); + + // Get count of invoices for this date + long count = invoiceRepository.countByInvoiceDate(invoiceDate); + + // Generate sequential number + long sequenceNumber = count + 1; + + // Format: INV-YYYYMMDD-XXX + String invoiceNumber = String.format("INV-%s-%d", dateStr, sequenceNumber); + + // Check if exists (race condition protection) + while (invoiceRepository.existsByInvoiceNumber(invoiceNumber)) { + sequenceNumber++; + invoiceNumber = String.format("INV-%s-%d", dateStr, sequenceNumber); + } + + return invoiceNumber; + } + + /** + * Get invoice by ID + */ + public Invoice getInvoiceById(Long id) { + return invoiceRepository.findById(id) + .orElseThrow(() -> new RuntimeException("Invoice not found with id: " + id)); + } + + /** + * Get invoice by invoice number + */ + public Invoice getInvoiceByNumber(String invoiceNumber) { + return invoiceRepository.findByInvoiceNumber(invoiceNumber) + .orElseThrow(() -> new RuntimeException("Invoice not found with number: " + invoiceNumber)); + } + + /** + * Get all invoices for a lease + */ + public List getInvoicesByLeaseId(Long leaseId) { + return invoiceRepository.findByLeaseId(leaseId); + } + + /** + * Payment Item DTO + */ + public static class PaymentItem { + private PaymentType paymentType; + private BigDecimal amount; + private String description; + + public PaymentItem() { + } + + public PaymentItem(PaymentType paymentType, BigDecimal amount, String description) { + this.paymentType = paymentType; + this.amount = amount; + this.description = description; + } + + public PaymentType getPaymentType() { + return paymentType; + } + + public void setPaymentType(PaymentType paymentType) { + this.paymentType = paymentType; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + } +} diff --git a/backend/src/main/java/apartment/example/backend/service/PaymentService.java b/backend/src/main/java/apartment/example/backend/service/PaymentService.java index 512ad0e..257dab2 100644 --- a/backend/src/main/java/apartment/example/backend/service/PaymentService.java +++ b/backend/src/main/java/apartment/example/backend/service/PaymentService.java @@ -207,7 +207,7 @@ public void deletePayment(Long id) { paymentRepository.delete(payment); } - private String generateReceiptNumber(PaymentType paymentType) { + public String generateReceiptNumber(PaymentType paymentType) { String prefix = switch (paymentType) { case RENT -> "RENT"; case ELECTRICITY -> "ELEC"; diff --git a/backend/src/main/java/apartment/example/backend/service/PdfService.java b/backend/src/main/java/apartment/example/backend/service/PdfService.java new file mode 100644 index 0000000..aa2b84e --- /dev/null +++ b/backend/src/main/java/apartment/example/backend/service/PdfService.java @@ -0,0 +1,194 @@ +package apartment.example.backend.service; + +import apartment.example.backend.entity.Invoice; +import apartment.example.backend.entity.Lease; +import apartment.example.backend.entity.Payment; +import apartment.example.backend.entity.Tenant; +import apartment.example.backend.entity.Unit; +import apartment.example.backend.entity.enums.PaymentType; +import apartment.example.backend.repository.InvoiceRepository; +import apartment.example.backend.repository.PaymentRepository; +import com.itextpdf.kernel.colors.ColorConstants; +import com.itextpdf.kernel.colors.DeviceRgb; +import com.itextpdf.kernel.pdf.PdfDocument; +import com.itextpdf.kernel.pdf.PdfWriter; +import com.itextpdf.layout.Document; +import com.itextpdf.layout.element.Cell; +import com.itextpdf.layout.element.Paragraph; +import com.itextpdf.layout.element.Table; +import com.itextpdf.layout.properties.TextAlignment; +import com.itextpdf.layout.properties.UnitValue; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.io.ByteArrayOutputStream; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +public class PdfService { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("MMMM dd, yyyy"); + + private final PaymentRepository paymentRepository; + private final InvoiceRepository invoiceRepository; + + /** + * Generate Lease Agreement PDF + * + * @param lease Lease entity with tenant and unit information + * @return PDF as byte array + */ + public byte[] generateLeaseAgreementPdf(Lease lease) { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + PdfWriter writer = new PdfWriter(baos); + PdfDocument pdfDoc = new PdfDocument(writer); + Document document = new Document(pdfDoc); + + Tenant tenant = lease.getTenant(); + Unit unit = lease.getUnit(); + + // Title + Paragraph title = new Paragraph("Rent Invoice") + .setFontSize(24) + .setBold() + .setTextAlignment(TextAlignment.CENTER) + .setMarginBottom(30); + document.add(title); + + // Invoice Details Table + Table invoiceDetailsTable = new Table(UnitValue.createPercentArray(new float[]{1, 1})) + .useAllAvailableWidth() + .setMarginBottom(20); + + // Header + Cell headerCell = new Cell(1, 2) + .add(new Paragraph("Invoice Details")) + .setBackgroundColor(new DeviceRgb(240, 240, 240)) + .setTextAlignment(TextAlignment.CENTER) + .setBold(); + invoiceDetailsTable.addHeaderCell(headerCell); + + // Get payments for this lease to get payment details + List payments = paymentRepository.findByLeaseId(lease.getId()); + + // Get latest invoice for this lease + List invoices = invoiceRepository.findByLeaseId(lease.getId()); + String invoiceNumber = invoices.isEmpty() ? + String.format("INV-%05d", lease.getId()) : + invoices.get(invoices.size() - 1).getInvoiceNumber(); // Get latest invoice + + LocalDate invoiceDate = invoices.isEmpty() ? + LocalDate.now() : + invoices.get(invoices.size() - 1).getInvoiceDate(); + + LocalDate dueDate = invoices.isEmpty() ? + lease.getEndDate() : + invoices.get(invoices.size() - 1).getDueDate(); + + // Invoice Details Rows + addTableRow(invoiceDetailsTable, "Invoice Number:", invoiceNumber); + addTableRow(invoiceDetailsTable, "Invoice Date:", invoiceDate.format(DATE_FORMATTER)); + addTableRow(invoiceDetailsTable, "Due Date:", dueDate.format(DATE_FORMATTER)); + + document.add(invoiceDetailsTable); + + // Bill To Section + Paragraph billToHeader = new Paragraph("Bill To:") + .setBold() + .setFontSize(12) + .setMarginTop(20) + .setMarginBottom(10); + document.add(billToHeader); + + document.add(new Paragraph("Name: " + tenant.getFirstName() + " " + tenant.getLastName())); + document.add(new Paragraph("Unit: " + unit.getRoomNumber())); + document.add(new Paragraph("Mail: " + tenant.getEmail()).setMarginBottom(20)); + + // Amount Details Table + Table amountTable = new Table(UnitValue.createPercentArray(new float[]{3, 1})) + .useAllAvailableWidth() + .setMarginTop(20); + + // Header + amountTable.addHeaderCell(createHeaderCell("Description")); + amountTable.addHeaderCell(createHeaderCell("Amount")); + + // Payments already fetched above, reuse the same list + // Find amounts by payment type + BigDecimal rentAmount = payments.stream() + .filter(p -> p.getPaymentType() == PaymentType.RENT) + .map(Payment::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal electricityAmount = payments.stream() + .filter(p -> p.getPaymentType() == PaymentType.ELECTRICITY) + .map(Payment::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + BigDecimal waterAmount = payments.stream() + .filter(p -> p.getPaymentType() == PaymentType.WATER) + .map(Payment::getAmount) + .reduce(BigDecimal.ZERO, BigDecimal::add); + + // Rows + addAmountRow(amountTable, "Monthly Rent", rentAmount.doubleValue()); + addAmountRow(amountTable, "Electricity", electricityAmount.doubleValue()); + addAmountRow(amountTable, "Water", waterAmount.doubleValue()); + + // Total Row + Cell totalLabelCell = new Cell() + .add(new Paragraph("Total")) + .setBold() + .setBackgroundColor(new DeviceRgb(230, 230, 250)) + .setTextAlignment(TextAlignment.CENTER); + + BigDecimal totalAmount = rentAmount.add(electricityAmount).add(waterAmount); + Cell totalAmountCell = new Cell() + .add(new Paragraph(String.format("%.2f Baht", totalAmount.doubleValue()))) + .setBold() + .setBackgroundColor(new DeviceRgb(230, 230, 250)) + .setTextAlignment(TextAlignment.RIGHT); + + amountTable.addCell(totalLabelCell); + amountTable.addCell(totalAmountCell); + + document.add(amountTable); + + document.close(); + + log.info("PDF generated successfully for lease ID: {}", lease.getId()); + return baos.toByteArray(); + + } catch (Exception e) { + log.error("Error generating PDF for lease ID: {}", lease.getId(), e); + throw new RuntimeException("Failed to generate PDF", e); + } + } + + private void addTableRow(Table table, String label, String value) { + table.addCell(new Cell().add(new Paragraph(label))); + table.addCell(new Cell().add(new Paragraph(value))); + } + + private Cell createHeaderCell(String text) { + return new Cell() + .add(new Paragraph(text).setBold()) + .setBackgroundColor(new DeviceRgb(240, 240, 240)) + .setTextAlignment(TextAlignment.CENTER); + } + + private void addAmountRow(Table table, String description, Double amount) { + table.addCell(new Cell().add(new Paragraph(description))); + + String amountText = amount != null ? String.format("%.2f Baht", amount) : ""; + table.addCell(new Cell() + .add(new Paragraph(amountText)) + .setTextAlignment(TextAlignment.RIGHT)); + } +} diff --git a/frontend/src/api/services/invoices.service.js b/frontend/src/api/services/invoices.service.js new file mode 100644 index 0000000..1288683 --- /dev/null +++ b/frontend/src/api/services/invoices.service.js @@ -0,0 +1,91 @@ +/** + * Invoices Service + * + * Handles all invoice-related API calls including creating invoices with payment line items + * + * @module api/services/invoices.service + */ + +import apiClient from '../client/apiClient'; + +/** + * Creates an invoice with multiple payment line items (RENT, ELECTRICITY, WATER) + * + * @async + * @function createInvoice + * @param {object} invoiceData - Invoice creation data + * @param {number} invoiceData.leaseId - ID of the associated lease + * @param {string} invoiceData.invoiceDate - Invoice date (YYYY-MM-DD format) + * @param {string} invoiceData.dueDate - Due date (YYYY-MM-DD format) + * @param {number} invoiceData.rentAmount - Rent amount (optional) + * @param {number} invoiceData.electricityAmount - Electricity amount (optional) + * @param {number} invoiceData.waterAmount - Water amount (optional) + * @param {string} [invoiceData.notes] - Optional invoice notes + * @returns {Promise<{id: number, invoiceNumber: string, lease: object, invoiceDate: string, dueDate: string, totalAmount: number, status: string, payments: Array}>} Created invoice with invoice number and payment line items + * @throws {Error} When invoice creation fails + * + * @example + * const invoice = await createInvoice({ + * leaseId: 1, + * invoiceDate: '2025-11-13', + * dueDate: '2025-11-28', + * rentAmount: 5000, + * electricityAmount: 800, + * waterAmount: 200, + * notes: 'ค่าเช่าประจำเดือน พฤศจิกายน 2025' + * }); + * console.log(`Invoice created: ${invoice.invoiceNumber}`); // INV-20251113-1 + */ +export const createInvoice = async (invoiceData) => { + return await apiClient.post('/invoices/create', invoiceData); +}; + +/** + * Retrieves an invoice by ID + * + * @async + * @function getInvoiceById + * @param {number} id - Invoice ID + * @returns {Promise<{id: number, invoiceNumber: string, lease: object, invoiceDate: string, dueDate: string, totalAmount: number, status: string, payments: Array}>} Invoice details with payment line items + * @throws {Error} When invoice not found or request fails + * + * @example + * const invoice = await getInvoiceById(1); + * console.log(`Invoice: ${invoice.invoiceNumber}`); + */ +export const getInvoiceById = async (id) => { + return await apiClient.get(`/invoices/${id}`); +}; + +/** + * Retrieves an invoice by invoice number + * + * @async + * @function getInvoiceByNumber + * @param {string} invoiceNumber - Invoice number (e.g., INV-20251113-1) + * @returns {Promise<{id: number, invoiceNumber: string, lease: object, invoiceDate: string, dueDate: string, totalAmount: number, status: string, payments: Array}>} Invoice details with payment line items + * @throws {Error} When invoice not found or request fails + * + * @example + * const invoice = await getInvoiceByNumber('INV-20251113-1'); + */ +export const getInvoiceByNumber = async (invoiceNumber) => { + return await apiClient.get(`/invoices/number/${invoiceNumber}`); +}; + +/** + * Retrieves all invoices for a specific lease + * + * @async + * @function getInvoicesByLeaseId + * @param {number} leaseId - Lease ID + * @returns {Promise>} Array of invoices for the lease + * @throws {Error} When fetching fails + * + * @example + * const invoices = await getInvoicesByLeaseId(1); + * console.log(`${invoices.length} invoices for this lease`); + */ +export const getInvoicesByLeaseId = async (leaseId) => { + return await apiClient.get(`/invoices/lease/${leaseId}`); +}; diff --git a/frontend/src/pages/admin/unit/send_bill/page.jsx b/frontend/src/pages/admin/unit/send_bill/page.jsx index 49d5afe..6ae22e3 100644 --- a/frontend/src/pages/admin/unit/send_bill/page.jsx +++ b/frontend/src/pages/admin/unit/send_bill/page.jsx @@ -2,7 +2,7 @@ import React, { useState, useEffect } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { getUnitDetails } from '../../../../api/services/units.service'; import { getUtilityRates } from '../../../../api/services/settings.service'; -import { createBillByAdmin } from '../../../../api/services/payments.service'; +import { createInvoice } from '../../../../api/services/invoices.service'; import { RiBillLine } from "react-icons/ri"; import { HiArrowLeft } from "react-icons/hi2"; @@ -32,6 +32,7 @@ function SendBillPage() { // Invoice details const [invoiceDate, setInvoiceDate] = useState(''); const [dueDate, setDueDate] = useState(''); + const [invoiceNumber, setInvoiceNumber] = useState(''); // Store invoice number from backend // Sending state const [sending, setSending] = useState(false); @@ -113,59 +114,47 @@ function SendBillPage() { const leaseId = lease.id; const currentMonth = new Date().toLocaleDateString('th-TH', { month: 'long', year: 'numeric' }); - console.log('Sending bills with data:', { + console.log('Creating invoice with data:', { leaseId, + invoiceDate, + dueDate, rentAmount: unit.rentAmount, electricityAmount, - waterAmount, + waterAmount + }); + + // Create invoice with payment line items + const invoice = await createInvoice({ + leaseId, + invoiceDate, dueDate, - currentMonth + rentAmount: unit.rentAmount, + electricityAmount, + waterAmount, + notes: `ค่าเช่าและค่าสาธารณูปโภคประจำเดือน ${currentMonth}` }); - // Create 3 payment records - await Promise.all([ - // 1. RENT - createBillByAdmin({ - leaseId, - paymentType: 'RENT', - amount: unit.rentAmount, - dueDate, - description: `ค่าเช่าประจำเดือน ${currentMonth}` - }), - // 2. ELECTRICITY - createBillByAdmin({ - leaseId, - paymentType: 'ELECTRICITY', - amount: electricityAmount, - dueDate, - description: `ค่าไฟฟ้า ${electricityUnits} หน่วย × ${electricityRate} ฿/หน่วย` - }), - // 3. WATER - createBillByAdmin({ - leaseId, - paymentType: 'WATER', - amount: waterAmount, - dueDate, - description: `ค่าน้ำ ${waterUnits} หน่วย × ${waterRate} ฿/หน่วย` - }) - ]); + console.log('✅ Invoice created successfully:', invoice); + console.log('Invoice Number:', invoice.invoiceNumber); + + // Store invoice number for display + setInvoiceNumber(invoice.invoiceNumber); // Success - navigate back - console.log('✅ Bills sent successfully!'); - alert('✅ ส่งบิลให้ผู้เช่าเรียบร้อยแล้ว'); + alert(`✅ ส่งบิลเรียบร้อยแล้ว\nเลขที่ใบแจ้งหนี้: ${invoice.invoiceNumber}`); navigate(`/admin/unit/${unit.id}`); } catch (err) { - console.error('Failed to send bill:', err); + console.error('Failed to create invoice:', err); console.error('Error response:', err.response); // Extract error message from response - let errorMessage = 'Failed to send bill. Please try again.'; + let errorMessage = 'Failed to create invoice. Please try again.'; if (err.response?.data?.message) { errorMessage = err.response.data.message; } else if (err.response?.status === 500) { errorMessage = 'Server error occurred. Please check the lease is active and try again.'; } else if (err.response?.status === 400) { - errorMessage = 'Invalid bill data. Please check all fields.'; + errorMessage = 'Invalid invoice data. Please check all fields.'; } else if (err.message) { errorMessage = err.message; } @@ -230,7 +219,9 @@ function SendBillPage() {
Invoice ID :
-
INV-{invoiceDate.replace(/-/g, '')}-{unit?.id}
+
+ {invoiceNumber ? invoiceNumber : `INV-${invoiceDate.replace(/-/g, '')}-${unit?.id}`} +
Invoice Date :