mirror of
https://github.com/rjNemo/rentease.git
synced 2026-06-06 02:36:49 +00:00
create invoice (#39)
### TL;DR Enhanced invoice generation with improved formatting and Euro symbol display ### What changed? - Added Euro symbol (€) to monetary values in the invoice template - Implemented new invoice data structure with dedicated types for lines and payments - Created ToInvoice method to properly format booking data for invoice generation - Added HTML template parsing and rendering functionality - Improved date formatting for consistency - Added new API endpoint for booking creation ### How to test? 1. Create a new booking through the API 2. Navigate to the PDF generation endpoint 3. Verify that monetary values display with Euro symbol 4. Check that dates are properly formatted 5. Confirm that payment history and totals are correctly calculated 6. Validate that the generated HTML maintains proper formatting ### Why make this change? To improve invoice readability and consistency by standardizing monetary value display and providing better data structure for invoice generation. This change also makes the system more maintainable by separating concerns between data transformation and presentation.
This commit is contained in:
parent
bfde4eb601
commit
cf1620592a
18 changed files with 318 additions and 579 deletions
|
|
@ -7,7 +7,7 @@ tmp_dir = "tmp"
|
|||
delay = 1000
|
||||
exclude_dir = ["assets", "tmp", "vendor"]
|
||||
exclude_file = []
|
||||
exclude_regex = [".*_templ.go"]
|
||||
exclude_regex = [".*_templ.go", "VFNI*html"]
|
||||
exclude_unchanged = false
|
||||
follow_symlink = false
|
||||
full_bin = ""
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,4 +29,4 @@ tmp.pdf
|
|||
*templ.txt
|
||||
token.json
|
||||
.aider*
|
||||
output.html
|
||||
VFNI*.html
|
||||
4
assets/css/pico.min.css
vendored
4
assets/css/pico.min.css
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,5 +1,6 @@
|
|||
<!doctype html>
|
||||
<html lang="fr">
|
||||
|
||||
<head>
|
||||
<style>
|
||||
body {
|
||||
|
|
@ -147,142 +148,143 @@
|
|||
</head>
|
||||
|
||||
<body>
|
||||
<div class="header space-between">
|
||||
<img class="logo" src="assets/img/logo.png"/>
|
||||
<div class="payee">
|
||||
<b>{{ .Host.Name }}</b><br/>
|
||||
{{ .Host.Address }}<br/>
|
||||
{{ .Host.ZipCode }} {{ .Host.City }}<br/>
|
||||
<b>Tel :</b> {{ .Host.Phone }}<br/>
|
||||
<b>Mail :</b> {{ .Host.Email }}<br/>
|
||||
<div class="header space-between">
|
||||
<img class="logo" src="/static/img/logo.png" />
|
||||
<div class="payee">
|
||||
<b>{{ .Host.Name }}</b><br />
|
||||
{{ .Host.Address }}<br />
|
||||
{{ .Host.ZipCode }} {{ .Host.City }}<br />
|
||||
<b>Tel :</b> {{ .Host.PhoneNumber }}<br />
|
||||
<b>Mail :</b> {{ .Host.Email }}<br />
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="billing-details space-between">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>{{ .Name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tel :</strong></td>
|
||||
<td>{{ .PhoneNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Nombre de clients :</strong></td>
|
||||
<td>{{ .CustomersNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plateforme :</strong></td>
|
||||
<td>{{ .Platform }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Nº de facture :</strong></td>
|
||||
<td>{{ .ID }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Du :</strong></td>
|
||||
<td>{{ .From }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Au :</strong></td>
|
||||
<td>{{ .To }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Montant Total :</strong></td>
|
||||
<td>{{ .Total }} €</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr />
|
||||
<div class="order-details">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Objet</th>
|
||||
<th>Quantité</th>
|
||||
<th>Prix (€)</th>
|
||||
<th class="align-right">Total (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-gray rounded">
|
||||
{{ range .Lines }}
|
||||
<tr class="item-row">
|
||||
<td class="text-break product_name">{{ .Name }}</td>
|
||||
<td>{{ .Quantity }}</td>
|
||||
<td>{{ .Price }}</td>
|
||||
<td class="align-right">{{ .Total }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="billing-details space-between">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>{{ .Name }}</strong></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Tel :</strong></td>
|
||||
<td>{{ .PhoneNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Client :</strong></td>
|
||||
<td>{{ .CustomersNumber }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Plateforme :</strong></td>
|
||||
<td>{{ .Platform }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Nº de facture :</strong></td>
|
||||
<td>{{ .ID }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Du :</strong></td>
|
||||
<td>{{ .From }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Au :</strong></td>
|
||||
<td>{{ .To }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Montant Total :</strong></td>
|
||||
<td>{{ .Total }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="order-details">
|
||||
<table class="items-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Objet</th>
|
||||
<th>Quantité</th>
|
||||
<th>Prix (€)</th>
|
||||
<th class="align-right">Total (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-gray rounded">
|
||||
{{ range .Lines }}
|
||||
<tr class="item-row">
|
||||
<td class="text-break product_name">{{ .Name }}</td>
|
||||
<td>{{ .Quantity }}</td>
|
||||
<td>{{ .Price }}</td>
|
||||
<td class="align-right">{{ .Total }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="payment-history">
|
||||
<h3>Historique des Paiements</h3>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Mode de Paiement</th>
|
||||
<th>Montant (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Payments }}
|
||||
<tr>
|
||||
<td>{{ .Date }}</td>
|
||||
<td>{{ .Method }}</td>
|
||||
<td>{{ .Amount }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="payment-history">
|
||||
<h3>Historique des Paiements</h3>
|
||||
<table class="history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Mode de Paiement</th>
|
||||
<th>Montant (€)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{ range .Payments }}
|
||||
<tr>
|
||||
<td>{{ .Date }}</td>
|
||||
<td>{{ .Method }}</td>
|
||||
<td>{{ .Amount }}</td>
|
||||
</tr>
|
||||
{{ end }}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="payment-summary space-between">
|
||||
<table class="summary-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Montant Total :</strong></td>
|
||||
<td>{{ .Total }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Montant Payé :</strong></td>
|
||||
<td>{{ .AmountPaid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Solde Restant :</strong></td>
|
||||
<td>{{ .BalanceDue }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<hr/>
|
||||
<div class="order-summary space-between">
|
||||
<div class="card">
|
||||
<b>Notes</b> <br/>
|
||||
TVA non applicable, art. 293 B du CGI <br/>
|
||||
Dispensé d’immatriculation au registre du commerce et des sociétés (RCS)
|
||||
et au répertoire des métiers. <br/>
|
||||
Conditions de paiement : paiement à réception de facture. Aucun escompte
|
||||
consenti pour règlement anticipé ou désistement. Tout incident de
|
||||
paiement est passible d'intérêts de retard. Le montant des pénalités
|
||||
résulte de l'application aux sommes restant dues d'un taux d'intérêt
|
||||
légal en vigueur au moment de l'incident. <br/>
|
||||
<div class="payment-summary space-between">
|
||||
<table class="summary-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><strong>Montant Total :</strong></td>
|
||||
<td>{{ .Total }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Montant Payé :</strong></td>
|
||||
<td>{{ .AmountPaid }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Solde Restant :</strong></td>
|
||||
<td>{{ .BalanceDue }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-total space-between">
|
||||
<div></div>
|
||||
<div class="total">
|
||||
<div class="amount-due">Total</div>
|
||||
<div class="amount-due-total">{{ .Total }}</div>
|
||||
<hr />
|
||||
<div class="order-summary space-between">
|
||||
<div class="card">
|
||||
<b>Notes</b> <br />
|
||||
TVA non applicable, art. 293 B du CGI <br />
|
||||
Dispensé d’immatriculation au registre du commerce et des sociétés (RCS)
|
||||
et au répertoire des métiers. <br />
|
||||
Conditions de paiement : paiement à réception de facture. Aucun escompte
|
||||
consenti pour règlement anticipé ou désistement. Tout incident de
|
||||
paiement est passible d'intérêts de retard. Le montant des pénalités
|
||||
résulte de l'application aux sommes restant dues d'un taux d'intérêt
|
||||
légal en vigueur au moment de l'incident. <br />
|
||||
</div>
|
||||
</div>
|
||||
<div class="order-total space-between">
|
||||
<div></div>
|
||||
<div class="total">
|
||||
<div class="amount-due">Total</div>
|
||||
<div class="amount-due-total">{{ .Total }} €</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
124
cmd/pdf/main.go
124
cmd/pdf/main.go
|
|
@ -1,124 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"html/template"
|
||||
"log"
|
||||
"os"
|
||||
)
|
||||
|
||||
const invoiceTemplate = "assets/html/invoice.html"
|
||||
|
||||
func main() {
|
||||
// Define the invoice data structure
|
||||
type Invoice struct {
|
||||
Host struct {
|
||||
Name string
|
||||
Address string
|
||||
ZipCode string
|
||||
City string
|
||||
Phone string
|
||||
Email string
|
||||
}
|
||||
Name string
|
||||
PhoneNumber string
|
||||
CustomersNumber int
|
||||
Platform string
|
||||
ID string
|
||||
From string
|
||||
To string
|
||||
Total string
|
||||
AmountPaid string
|
||||
BalanceDue string
|
||||
Lines []struct {
|
||||
Name string
|
||||
Quantity int
|
||||
Price string
|
||||
Total string
|
||||
}
|
||||
Payments []struct {
|
||||
Date string
|
||||
Method string
|
||||
Amount string
|
||||
}
|
||||
}
|
||||
|
||||
// Read the template file
|
||||
tmpl, err := template.ParseFiles(invoiceTemplate)
|
||||
if err != nil {
|
||||
log.Fatalf("Error parsing template: %v", err)
|
||||
}
|
||||
|
||||
// Create sample invoice data
|
||||
invoice := Invoice{
|
||||
Host: struct {
|
||||
Name string
|
||||
Address string
|
||||
ZipCode string
|
||||
City string
|
||||
Phone string
|
||||
Email string
|
||||
}{
|
||||
Name: "VillaFleurie",
|
||||
Address: "4 rue Gerty Archimede",
|
||||
ZipCode: "97190",
|
||||
City: "Le Gosier",
|
||||
Phone: "+590 690 44 15 30",
|
||||
Email: "location.villafleurie@gmail.com",
|
||||
},
|
||||
Name: "Michel Le Corre",
|
||||
//PhoneNumber: "+590 690 44 15 30",
|
||||
CustomersNumber: 2,
|
||||
Platform: "Privée",
|
||||
ID: "VFNI0332",
|
||||
From: "15 Janvier 2025",
|
||||
To: "15 Mars 2025",
|
||||
Total: "2065.00 €",
|
||||
AmountPaid: "1565.00 €",
|
||||
BalanceDue: "500.00 €",
|
||||
Lines: []struct {
|
||||
Name string
|
||||
Quantity int
|
||||
Price string
|
||||
Total string
|
||||
}{
|
||||
{
|
||||
Name: "T2",
|
||||
Quantity: 59,
|
||||
Price: "35.00",
|
||||
Total: "2065.00",
|
||||
},
|
||||
},
|
||||
Payments: []struct {
|
||||
Date string
|
||||
Method string
|
||||
Amount string
|
||||
}{
|
||||
{
|
||||
"–",
|
||||
"Espèces",
|
||||
"500.00",
|
||||
},
|
||||
{
|
||||
"–",
|
||||
"Chèque",
|
||||
"1065.00",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Create a buffer to store the rendered HTML
|
||||
var buf bytes.Buffer
|
||||
// Execute the template with the invoice data
|
||||
if err := tmpl.Execute(&buf, invoice); err != nil {
|
||||
log.Fatalf("Error executing template: %v", err)
|
||||
}
|
||||
|
||||
// Write the rendered HTML to a file
|
||||
outputPath := "output.html"
|
||||
if err := os.WriteFile(outputPath, buf.Bytes(), 0644); err != nil {
|
||||
log.Fatalf("Error writing HTML file: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("HTML file created successfully: %s", outputPath)
|
||||
}
|
||||
40
internal/driver/pdf/html.go
Normal file
40
internal/driver/pdf/html.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package pdf
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"text/template"
|
||||
|
||||
"github.com/rjNemo/rentease/internal/service/booking"
|
||||
)
|
||||
|
||||
type HtmlPdfClient struct{}
|
||||
|
||||
func NewPdfClient() (*HtmlPdfClient, error) {
|
||||
return &HtmlPdfClient{}, nil
|
||||
}
|
||||
|
||||
func (pc *HtmlPdfClient) BuildInvoice(data booking.Invoice) (string, error) {
|
||||
tmpl, err := template.ParseFiles("assets/html/invoice.html")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("Error parsing template: %v", err)
|
||||
}
|
||||
|
||||
// Create a buffer to hold the rendered HTML.
|
||||
var buf bytes.Buffer
|
||||
if err := tmpl.Execute(&buf, data); err != nil {
|
||||
return "", fmt.Errorf("error executing template: %v", err)
|
||||
}
|
||||
|
||||
outputPath := fmt.Sprintf("%s.html", data.ID)
|
||||
if err := os.WriteFile(outputPath, buf.Bytes(), 0644); err != nil {
|
||||
return "", fmt.Errorf("error writing HTML file: %v", err)
|
||||
}
|
||||
|
||||
return outputPath, nil
|
||||
}
|
||||
|
||||
func (pc *HtmlPdfClient) BuildReport(context map[string]any, period string, month int, year int) error {
|
||||
panic("unimplemented")
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ type PdfClient struct {
|
|||
apiKey string
|
||||
}
|
||||
|
||||
func NewPdfClient(pid, rid, url, key string) (*PdfClient, error) {
|
||||
func NewApiPdfClient(pid, rid, url, key string) (*PdfClient, error) {
|
||||
if pid == "" || rid == "" || url == "" || key == "" {
|
||||
return nil, errors.New("error building Pdf service. Verify your env variables")
|
||||
}
|
||||
|
|
@ -40,3 +40,32 @@ func handleSync(bs *booking.Service) echo.HandlerFunc {
|
|||
return c.JSON(http.StatusCreated, "👍")
|
||||
}
|
||||
}
|
||||
|
||||
func handleCreateBooking(bs *booking.Service) echo.HandlerFunc {
|
||||
type BookingInfo struct {
|
||||
Content string `json:"content"`
|
||||
}
|
||||
return func(c echo.Context) error {
|
||||
log.Info("received booking sync request from booking")
|
||||
x := c.Request().Body
|
||||
body, err := io.ReadAll(x)
|
||||
if err != nil {
|
||||
return c.String(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
log.Info(string(body))
|
||||
|
||||
bookingInfo := new(BookingInfo)
|
||||
err = json.Unmarshal(body, bookingInfo)
|
||||
if err != nil {
|
||||
return c.String(http.StatusInternalServerError, fmt.Sprintf("error unmarshalling JSON: %s", err))
|
||||
}
|
||||
|
||||
b, err := bs.ParseFromApi(bookingInfo.Content)
|
||||
if err != nil {
|
||||
return c.String(http.StatusInternalServerError, err.Error())
|
||||
}
|
||||
log.Infof("created booking %q from %q", b.Name, b.Platform)
|
||||
|
||||
return c.JSON(http.StatusCreated, "👍")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package server
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
|
|
@ -17,16 +18,18 @@ func handlePdfCreateInvoice(bs *booking.Service, hc *config.Host) echo.HandlerFu
|
|||
idStr := c.Param("id")
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
|
||||
b := bs.One(id)
|
||||
|
||||
err = bs.BuildInvoice(b, hc)
|
||||
filePath, err := bs.BuildInvoice(b, hc)
|
||||
if err != nil {
|
||||
log.Println(err)
|
||||
return err
|
||||
}
|
||||
return c.Attachment("tmp.pdf", fmt.Sprintf("VFNI-%s.pdf", b.InvoiceNumber(hc)))
|
||||
return c.File(filePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ func (s Server) MountHandlers() {
|
|||
}))
|
||||
api.POST("/sync", handleSync(s.bs))
|
||||
api.GET("/bookings", handleBookingList(s.bs))
|
||||
api.POST("/bookings", handleCreateBooking(s.bs))
|
||||
|
||||
private := s.Router.Group("")
|
||||
private.Use(MakeAuthMiddleware(s.as))
|
||||
|
|
|
|||
32
internal/service/booking/invoice.go
Normal file
32
internal/service/booking/invoice.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
package booking
|
||||
|
||||
import "github.com/rjNemo/rentease/internal/config"
|
||||
|
||||
type InvoiceLine struct {
|
||||
Name string
|
||||
Quantity int
|
||||
Price string
|
||||
Total string
|
||||
}
|
||||
|
||||
type PaymentLine struct {
|
||||
Date string
|
||||
Method string
|
||||
Amount string
|
||||
}
|
||||
|
||||
type Invoice struct {
|
||||
Host config.Host
|
||||
Name string
|
||||
PhoneNumber string
|
||||
CustomersNumber int
|
||||
Platform string
|
||||
ID string
|
||||
From string
|
||||
To string
|
||||
Total string
|
||||
AmountPaid string
|
||||
BalanceDue string
|
||||
Lines []InvoiceLine
|
||||
Payments []PaymentLine
|
||||
}
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
package booking
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
// MockStore is a mock implementation of the Store interface
|
||||
type MockStore struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockStore) All() []*Line {
|
||||
args := m.Called()
|
||||
return args.Get(0).([]*Line)
|
||||
}
|
||||
|
||||
func (m *MockStore) Search(value string) []*Line {
|
||||
args := m.Called(value)
|
||||
return args.Get(0).([]*Line)
|
||||
}
|
||||
|
||||
func (m *MockStore) List(from, to time.Time) ([]*Line, error) {
|
||||
args := m.Called(from, to)
|
||||
return args.Get(0).([]*Line), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) CardTotal(from, to time.Time) (float64, error) {
|
||||
args := m.Called(from, to)
|
||||
return args.Get(0).(float64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) Get(id int) *Booking {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(*Booking)
|
||||
}
|
||||
|
||||
func (m *MockStore) Create(b *Booking) error {
|
||||
args := m.Called(b)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Update(b *Booking) error {
|
||||
args := m.Called(b)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) Cancel(id int) error {
|
||||
args := m.Called(id)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) CreateItem(i *Item) error {
|
||||
args := m.Called(i)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockStore) PayItem(id int) (*Item, error) {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(*Item), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) GetItem(id int) (*Item, error) {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(*Item), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) UpdateItem(id int, item string, paymentMethod string, paymentStatus string, qty int, price float64) (*Item, error) {
|
||||
args := m.Called(id, item, paymentMethod, paymentStatus, qty, price)
|
||||
return args.Get(0).(*Item), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) CreatePayment(p *Payment) (*Payment, error) {
|
||||
args := m.Called(p)
|
||||
return args.Get(0).(*Payment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) GetPayment(id int) (*Payment, error) {
|
||||
args := m.Called(id)
|
||||
return args.Get(0).(*Payment), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockStore) UpdatePayment(id int, amount float64, paymentMethod string) (*Payment, error) {
|
||||
args := m.Called(id, amount, paymentMethod)
|
||||
return args.Get(0).(*Payment), args.Error(1)
|
||||
}
|
||||
|
||||
// MockParserClient is a mock implementation of the parser.Client interface
|
||||
type MockParserClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
// MockCalendarClient is a mock implementation of the calendar.Client interface
|
||||
type MockCalendarClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockCalendarClient) Create(calendarId, name, description string, from, to time.Time) error {
|
||||
args := m.Called(calendarId, name, description, from, to)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
// MockPDFClient is a mock implementation of the pdf.Client interface
|
||||
type MockPDFClient struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockPDFClient) BuildInvoice(context map[string]any) error {
|
||||
args := m.Called(context)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
||||
func (m *MockPDFClient) BuildReport(context map[string]any, period string, month, year int) error {
|
||||
args := m.Called(context, period, month, year)
|
||||
return args.Error(0)
|
||||
}
|
||||
|
|
@ -49,34 +49,41 @@ func (b Booking) InvoiceNumber(hc *config.Host) string {
|
|||
return fmt.Sprintf("%s%04s", hc.InvoicePrefix, strconv.Itoa(b.Id+hc.CustomerSeed))
|
||||
}
|
||||
|
||||
func (b Booking) Serialize(hc *config.Host) map[string]any {
|
||||
return map[string]any{
|
||||
"host": map[string]any{
|
||||
"name": hc.Name,
|
||||
"address": hc.Address,
|
||||
"zip": hc.ZipCode,
|
||||
"city": hc.City,
|
||||
"phone": hc.PhoneNumber,
|
||||
"email": hc.Email,
|
||||
},
|
||||
"id": b.InvoiceNumber(hc),
|
||||
"name": b.Name,
|
||||
"phone_number": b.PhoneNumber,
|
||||
"customers_number": b.CustomerNumber,
|
||||
"platform": b.Platform,
|
||||
"from": b.From.Format("02/01/2006"),
|
||||
"to": b.To.Format("02/01/2006"),
|
||||
"lines": u.Map(b.Items, func(i Item) map[string]any {
|
||||
return map[string]any{
|
||||
"name": i.ToFrench(),
|
||||
"quantity": i.Quantity,
|
||||
"price": i.Price,
|
||||
"total": i.Price * float64(i.Quantity),
|
||||
func (b Booking) ToInvoice(hc *config.Host) Invoice {
|
||||
total := u.Reduce(b.Items, func(i Item, sum float64) float64 {
|
||||
return sum + i.Price*float64(i.Quantity)
|
||||
}, 0.0)
|
||||
amountPaid := u.Reduce(b.Payments, func(i Payment, sum float64) float64 {
|
||||
return sum + i.Amount
|
||||
}, 0.0)
|
||||
|
||||
return Invoice{
|
||||
Host: *hc,
|
||||
Name: b.Name,
|
||||
PhoneNumber: b.PhoneNumber,
|
||||
CustomersNumber: b.CustomerNumber,
|
||||
Platform: b.Platform,
|
||||
ID: b.InvoiceNumber(hc),
|
||||
From: b.From.Format("02/01/2006"),
|
||||
To: b.To.Format("02/01/2006"),
|
||||
Total: strconv.FormatFloat(total, 'f', 2, 64),
|
||||
AmountPaid: strconv.FormatFloat(amountPaid, 'f', 2, 64),
|
||||
BalanceDue: strconv.FormatFloat(total-amountPaid, 'f', 2, 64),
|
||||
Lines: u.Map(b.Items, func(i Item) InvoiceLine {
|
||||
return InvoiceLine{
|
||||
Name: i.ToFrench(),
|
||||
Quantity: i.Quantity,
|
||||
Price: strconv.FormatFloat(i.Price, 'f', 2, 64),
|
||||
Total: strconv.FormatFloat(i.Price*float64(i.Quantity), 'f', 2, 64),
|
||||
}
|
||||
}),
|
||||
Payments: u.Map(b.Payments, func(p Payment) PaymentLine {
|
||||
return PaymentLine{
|
||||
Date: p.CreatedAt.Format("02/01/2006"),
|
||||
Method: p.PaymentMethod,
|
||||
Amount: strconv.FormatFloat(p.Amount, 'f', 2, 64),
|
||||
}
|
||||
}),
|
||||
"total": strconv.FormatFloat(u.Reduce(b.Items, func(i Item, sum float64) float64 {
|
||||
return sum + i.Price*float64(i.Quantity)
|
||||
}, 0.0), 'f', 2, 64),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ type Store interface {
|
|||
}
|
||||
|
||||
type PdfClient interface {
|
||||
BuildInvoice(context map[string]any) error
|
||||
BuildInvoice(invoice Invoice) (string, error)
|
||||
BuildReport(context map[string]any, period string, month, year int) error
|
||||
}
|
||||
|
||||
|
|
@ -100,6 +100,6 @@ func (bs Service) Cancel(id int) {
|
|||
}
|
||||
}
|
||||
|
||||
func (bs Service) BuildInvoice(b *Booking, hc *config.Host) error {
|
||||
return bs.pdf.BuildInvoice(b.Serialize(hc))
|
||||
func (bs Service) BuildInvoice(b *Booking, hc *config.Host) (string, error) {
|
||||
return bs.pdf.BuildInvoice(b.ToInvoice(hc))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
package booking
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestService_All(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mockData []*Line
|
||||
}{
|
||||
{
|
||||
name: "returns empty list when no bookings",
|
||||
mockData: []*Line{},
|
||||
},
|
||||
{
|
||||
name: "returns list of bookings",
|
||||
mockData: []*Line{
|
||||
{
|
||||
Id: 1,
|
||||
CustomerName: "John Doe",
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 5, 0, 0, 0, 0, time.UTC),
|
||||
Platform: "Airbnb",
|
||||
Total: 500.0,
|
||||
Canceled: false,
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CustomerName: "Jane Smith",
|
||||
From: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
|
||||
Platform: "Booking.com",
|
||||
Total: 300.0,
|
||||
Canceled: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mocks
|
||||
mockStore := new(MockStore)
|
||||
mockCalendar := new(MockCalendarClient)
|
||||
mockPDF := new(MockPDFClient)
|
||||
|
||||
// Set up expectations
|
||||
mockStore.On("All").Return(tt.mockData)
|
||||
|
||||
// Create service with mocks
|
||||
service, err := NewService(mockStore, nil, mockCalendar, mockPDF)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Call the method
|
||||
result := service.All()
|
||||
|
||||
// Assert expectations
|
||||
assert.Equal(t, tt.mockData, result)
|
||||
mockStore.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Search(t *testing.T) {
|
||||
sampleBookings := []*Line{
|
||||
{
|
||||
Id: 1,
|
||||
CustomerName: "John Doe",
|
||||
From: time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 1, 5, 0, 0, 0, 0, time.UTC),
|
||||
Platform: "Airbnb",
|
||||
Total: 500.0,
|
||||
Canceled: false,
|
||||
},
|
||||
{
|
||||
Id: 2,
|
||||
CustomerName: "Jane Smith",
|
||||
From: time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC),
|
||||
To: time.Date(2024, 2, 3, 0, 0, 0, 0, time.UTC),
|
||||
Platform: "Booking.com",
|
||||
Total: 300.0,
|
||||
Canceled: true,
|
||||
},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
searchQuery string
|
||||
expectedLines []*Line
|
||||
expectedCalled bool
|
||||
}{
|
||||
{
|
||||
name: "empty search query returns no results",
|
||||
searchQuery: "",
|
||||
expectedLines: []*Line{},
|
||||
expectedCalled: false,
|
||||
},
|
||||
{
|
||||
name: "search for 'John' returns matching booking",
|
||||
searchQuery: "John",
|
||||
expectedLines: []*Line{
|
||||
sampleBookings[0],
|
||||
},
|
||||
expectedCalled: true,
|
||||
},
|
||||
{
|
||||
name: "search for 'Smith' returns matching booking",
|
||||
searchQuery: "Smith",
|
||||
expectedLines: []*Line{
|
||||
sampleBookings[1],
|
||||
},
|
||||
expectedCalled: true,
|
||||
},
|
||||
{
|
||||
name: "search for non-existent name returns empty list",
|
||||
searchQuery: "NonExistent",
|
||||
expectedLines: []*Line{},
|
||||
expectedCalled: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Create mocks
|
||||
mockStore := new(MockStore)
|
||||
mockCalendar := new(MockCalendarClient)
|
||||
mockPDF := new(MockPDFClient)
|
||||
|
||||
// Set up expectations
|
||||
mockStore.On("Search", tt.searchQuery).Return(tt.expectedLines)
|
||||
|
||||
// Create service with mocks
|
||||
service, err := NewService(mockStore, nil, mockCalendar, mockPDF)
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Call the method
|
||||
result := service.Search(tt.searchQuery)
|
||||
|
||||
// Assert expectations
|
||||
assert.Equal(t, tt.expectedLines, result)
|
||||
mockStore.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -14,16 +14,30 @@ templ BookingById(booking *BookingViewModel) {
|
|||
</hgroup>
|
||||
<div class="flex items-center gap-4">
|
||||
<a class="btn btn-primary btn-sm" href={ booking.PdfUrl } target="_blank">Create PDF</a>
|
||||
<a href="https://web.whatsapp.com/" target="_blank" rel="noreferrer noopener" class="btn btn-ghost btn-sm btn-square">
|
||||
<a
|
||||
href="https://web.whatsapp.com/"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
>
|
||||
<img src="/static/icons/whatsapp.png" class="w-6 h-6"/>
|
||||
</a>
|
||||
<a href="https://dashboard.stripe.com/payments/new" target="_blank" rel="noreferrer noopener" class="btn btn-ghost btn-sm btn-square">
|
||||
<a
|
||||
href="https://dashboard.stripe.com/payments/new"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
class="btn btn-ghost btn-sm btn-square"
|
||||
>
|
||||
<img src="/static/icons/stripe.png" class="w-6 h-6"/>
|
||||
</a>
|
||||
if booking.Canceled {
|
||||
<span class="badge badge-error">Canceled</span>
|
||||
} else {
|
||||
<button class="btn btn-outline btn-error btn-sm" hx-patch={ booking.CancelUrl } hx-swap="outerHTML">Cancel</button>
|
||||
<button
|
||||
class="btn btn-outline btn-error btn-sm"
|
||||
hx-patch={ booking.CancelUrl }
|
||||
hx-swap="outerHTML"
|
||||
>Cancel</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -31,7 +45,9 @@ templ BookingById(booking *BookingViewModel) {
|
|||
@BookingForm(*booking)
|
||||
</section>
|
||||
<section class="p-4 bg-base-100 rounded-lg shadow-sm">
|
||||
<h3 class="text-xl font-semibold mb-4 flex justify-between items-center">Line Items <button class="btn btn-sm btn-success" onclick="payment_modal.showModal()">Add Payment</button></h3>
|
||||
<h3 class="text-xl font-semibold mb-4 flex justify-between items-center">
|
||||
Line Items <button class="btn btn-sm btn-success" onclick="payment_modal.showModal()">Add Payment</button>
|
||||
</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra w-full">
|
||||
<thead>
|
||||
|
|
@ -57,7 +73,14 @@ templ BookingById(booking *BookingViewModel) {
|
|||
</section>
|
||||
<details class="collapse bg-base-200 mt-8">
|
||||
<summary class="collapse-title text-xl font-medium flex items-center gap-2 hover:bg-base-300">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
class="w-6 h-6"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4.5v15m7.5-7.5h-15"></path>
|
||||
</svg>
|
||||
Add New Line Item
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var6 string
|
||||
templ_7745c5c3_Var6, templ_7745c5c3_Err = templ.JoinStringErrs(booking.CancelUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 26, Col: 82}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 38, Col: 34}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var6))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -132,7 +132,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var7 string
|
||||
templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(booking.Total)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 51, Col: 26}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 67, Col: 26}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -145,7 +145,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var8 string
|
||||
templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(fmt.Sprintf("%s/items", booking.Url))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 67, Col: 51}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 90, Col: 51}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -163,7 +163,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var9 string
|
||||
templ_7745c5c3_Var9, templ_7745c5c3_Err = templ.JoinStringErrs(item)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 79, Col: 28}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 102, Col: 28}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var9))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -176,7 +176,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var10 string
|
||||
templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(item)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 79, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 102, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -199,7 +199,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var11 string
|
||||
templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(paymentMethod)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 115, Col: 37}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 138, Col: 37}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -212,7 +212,7 @@ func BookingById(booking *BookingViewModel) templ.Component {
|
|||
var templ_7745c5c3_Var12 string
|
||||
templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(paymentMethod)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 115, Col: 55}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 138, Col: 55}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
@ -269,7 +269,7 @@ func PaymentModal(paymentUrl string) templ.Component {
|
|||
var templ_7745c5c3_Var14 string
|
||||
templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(paymentUrl)
|
||||
if templ_7745c5c3_Err != nil {
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 137, Col: 24}
|
||||
return templ.Error{Err: templ_7745c5c3_Err, FileName: `internal/view/booking_by_id.templ`, Line: 160, Col: 24}
|
||||
}
|
||||
_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14))
|
||||
if templ_7745c5c3_Err != nil {
|
||||
|
|
|
|||
7
main.go
7
main.go
|
|
@ -65,12 +65,7 @@ func run(c context.Context, getEnv func(string) string) error {
|
|||
}
|
||||
|
||||
// build pdf client
|
||||
pc, err := pdf.NewPdfClient(
|
||||
getEnv("HTMLDOCS_PROJECT_ID"),
|
||||
getEnv("HTMLDOCS_REPORT_PROJECT_ID"),
|
||||
getEnv("HTMLDOCS_URL"),
|
||||
getEnv("HTMLDOCS_KEY"),
|
||||
)
|
||||
pc, err := pdf.NewPdfClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("error starting pdf client %w", err)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue