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:
Ruidy 2025-02-04 11:34:14 +01:00 committed by GitHub
parent bfde4eb601
commit cf1620592a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 318 additions and 579 deletions

View file

@ -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
View file

@ -29,4 +29,4 @@ tmp.pdf
*templ.txt
token.json
.aider*
output.html
VFNI*.html

File diff suppressed because one or more lines are too long

View file

@ -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é dimmatriculation 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é dimmatriculation 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>

View file

@ -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)
}

View 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")
}

View file

@ -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")
}

View file

@ -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, "👍")
}
}

View file

@ -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)
}
}

View file

@ -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))

View 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
}

View file

@ -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)
}

View file

@ -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),
}
}

View file

@ -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))
}

View file

@ -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)
})
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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)
}