wip: improve invoice generation logic

This commit is contained in:
2025-08-14 20:00:21 +01:00
parent a85e13e17d
commit 7f4da2f606
10 changed files with 165 additions and 42 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ to quickly create a Cobra application.`,
// Uncomment the following line if your bare application
// has an action associated with it:
Run: func(cmd *cobra.Command, args []string) {
dir, err := lib.GetConfigDirectory()
dir, err := lib.GetConfigDirectoryPath()
if err != nil {
log.Fatal(err)
+1 -1
View File
@@ -7,6 +7,7 @@ require (
github.com/go-playground/validator/v10 v10.26.0
github.com/google/uuid v1.6.0
github.com/gorilla/sessions v1.4.0
github.com/johnfercher/maroto/v2 v2.3.1
github.com/labstack/echo-contrib v0.17.4
github.com/labstack/echo/v4 v4.13.4
github.com/spf13/cobra v1.9.1
@@ -38,7 +39,6 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/johnfercher/go-tree v1.0.5 // indirect
github.com/johnfercher/maroto/v2 v2.3.1 // indirect
github.com/jung-kurt/gofpdf v1.16.2 // indirect
github.com/labstack/gommon v0.4.2 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
+10
View File
@@ -104,6 +104,10 @@ github.com/johnfercher/maroto/v2 v2.3.1/go.mod h1:/LfW6AQGZzsG6xUixcfyxkKztDoszd
github.com/jung-kurt/gofpdf v1.0.0/go.mod h1:7Id9E/uU8ce6rXgefFLlgrJj/GYY22cpxn+r32jIOes=
github.com/jung-kurt/gofpdf v1.16.2 h1:jgbatWHfRlPYiK85qgevsZTHviWXKwB1TTiKdz5PtRc=
github.com/jung-kurt/gofpdf v1.16.2/go.mod h1:1hl7y57EsiPAkLbOwzpzqgx1A30nQCk/YmFV8S2vmK0=
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo-contrib v0.17.4 h1:g5mfsrJfJTKv+F5uNKCyrjLK7js+ZW6HTjg4FnDxxgk=
@@ -142,6 +146,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.4 h1:8TfxU8dW6PdqD27gjM8MVNuicgxIjxpm4K7x4jp8sis=
github.com/rivo/uniseg v0.4.4/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ruudk/golang-pdf417 v0.0.0-20181029194003-1af4ab5afa58/go.mod h1:6lfFZQK844Gfx8o5WFuvpxWRwnSoipWe/p622j1v06w=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
@@ -149,6 +155,8 @@ github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wx
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
@@ -258,6 +266,8 @@ golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/natefinch/npipe.v2 v2.0.0-20160621034901-c1b8fa8bdcce/go.mod h1:5AcXVHNjg+BDxry382+8OKon8SEWiKktQR07RKPsv1c=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+83 -2
View File
@@ -85,9 +85,26 @@ func (api *API) CreateInvoiceHandler(context echo.Context) error {
return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Unexpected error getting User."), Messages: []string{"Unexpected error getting User!"}}
}
lib.GenerateInvoice(invoice, user, client)
path, err := lib.GenerateInvoice(invoice, user, client)
return context.Attachment(fmt.Sprintf("invoices/%s.pdf", invoice.ID), fmt.Sprintf("invoices/%s.pdf", invoice.ID))
if err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}}
}
invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID)
invoiceId, err := uuid.Parse(invoice.ID)
if err != nil {
return types.Error{Code: http.StatusBadRequest, Messages: []string{"Could not parse Invoice ID!"}}
}
err = api.db.UpdateInvoicePDF(userId, invoiceId, path, &invoice)
if err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}}
}
return context.Attachment(path, invoiceFileName)
}
func (api *API) GetAllItemsHandler(context echo.Context) error {
@@ -172,6 +189,70 @@ func (api *API) GetInvoiceHandler(context echo.Context) error {
return context.JSON(http.StatusOK, invoice)
}
func (api *API) DownloadInvoiceHandler(context echo.Context) error {
userId, err := uuid.Parse(context.Get("id").(string))
if err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Session ID not found after authorization."), Messages: []string{"Unexpected error getting User!"}}
}
id, err := uuid.Parse(context.Param("id"))
if err != nil {
return types.Error{Code: http.StatusBadRequest, Messages: []string{"ID is required to download an Invoice!"}}
}
var invoice types.Invoice
if err := api.db.GetInvoiceById(userId, id, &invoice); err != nil {
return types.Error{Code: http.StatusNotFound, Messages: []string{"Invoice not found!"}}
}
if invoice.PDF != "" {
invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID)
return context.Attachment(invoice.PDF, invoiceFileName)
}
var user types.User
if err := api.db.GetUserById(userId, &user); err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Unexpected error getting User."), Messages: []string{"Unexpected error getting User!"}}
}
clientId, err := uuid.Parse(invoice.ClientID)
if err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Invalid Client ID."), Messages: []string{"Unexpected error getting User!"}}
}
var client types.Client
if err := api.db.GetClientById(userId, clientId, &client); err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: errors.New("Unexpected error getting Client."), Messages: []string{"Unexpected error getting User!"}}
}
path, err := lib.GenerateInvoice(invoice, user, client)
if err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}}
}
invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID)
invoiceId, err := uuid.Parse(invoice.ID)
if err != nil {
return types.Error{Code: http.StatusBadRequest, Messages: []string{"Could not parse Invoice ID!"}}
}
err = api.db.UpdateInvoicePDF(userId, invoiceId, path, &invoice)
if err != nil {
return types.Error{Code: http.StatusInternalServerError, Cause: err, Messages: []string{"Unexpected error generating Invoice!"}}
}
return context.Attachment(path, invoiceFileName)
}
func (api *API) UpdateItemHandler(context echo.Context) error {
userId, err := uuid.Parse(context.Get("id").(string))
+1
View File
@@ -32,6 +32,7 @@ func (api *API) InvoiceRoutes() {
invoices.POST("/", api.CreateInvoiceHandler)
invoices.POST("/:id/items/", api.CreateItemHandler)
invoices.GET("/:id/", api.GetInvoiceHandler)
invoices.GET("/:id/download/", api.DownloadInvoiceHandler)
invoices.GET("/:id/items/", api.GetAllItemsHandler)
invoices.GET("/items/:id/", api.GetItemHandler)
invoices.PUT("/:id/", api.UpdateInvoiceHandler)
+22 -34
View File
@@ -9,7 +9,6 @@ import (
"github.com/google/uuid"
"github.com/gorilla/sessions"
"github.com/johnfercher/maroto/v2"
"github.com/johnfercher/maroto/v2/pkg/components/code"
"github.com/johnfercher/maroto/v2/pkg/components/col"
"github.com/johnfercher/maroto/v2/pkg/components/image"
"github.com/johnfercher/maroto/v2/pkg/components/row"
@@ -25,7 +24,7 @@ import (
"github.com/hazemKrimi/crimson-vault/internal/types"
)
func GetConfigDirectory() (string, error) {
func GetConfigDirectoryPath() (string, error) {
home, err := os.UserHomeDir()
if err != nil {
@@ -172,7 +171,7 @@ func getTransactions(items []types.Item) []core.Row {
col.New(3),
text.NewCol(4, item.Name, props.Text{Size: 8, Align: align.Center}),
text.NewCol(2, fmt.Sprintf("%d", item.Quantity), props.Text{Size: 8, Align: align.Center}),
text.NewCol(3, fmt.Sprintf("%d", item.Price), props.Text{Size: 8, Align: align.Center}),
text.NewCol(3, fmt.Sprintf("%.2f", item.Price), props.Text{Size: 8, Align: align.Center}),
)
if i%2 == 0 {
gray := getGrayColor()
@@ -203,7 +202,7 @@ func getTransactions(items []types.Item) []core.Row {
return rows
}
func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client) error {
func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client) (string, error) {
cfg := config.NewBuilder().WithPageNumber().WithLeftMargin(10).WithRightMargin(10).WithTopMargin(15).Build()
darkGray := getDarkGrayColor()
mrt := maroto.New(cfg)
@@ -212,23 +211,23 @@ func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client
err := m.RegisterHeader(getPageHeader(client, user.Logo))
if err != nil {
return err
return "", err
}
err = m.RegisterFooter(getPageFooter(user))
if err != nil {
return err
return "", err
}
m.AddRows(text.NewRow(10, "Invoice ABC123456789", props.Text{
m.AddRows(text.NewRow(10, fmt.Sprintf("Invoice %s", invoice.ID), props.Text{
Top: 3,
Style: fontstyle.Bold,
Align: align.Center,
}))
m.AddRow(7,
text.NewCol(3, "Transactions", props.Text{
text.NewCol(3, "Items", props.Text{
Top: 1.5,
Size: 9,
Style: fontstyle.Bold,
@@ -239,43 +238,32 @@ func GenerateInvoice(invoice types.Invoice, user types.User, client types.Client
m.AddRows(getTransactions(invoice.Items)...)
m.AddRow(15,
col.New(6).Add(
code.NewBar("5123.151231.512314.1251251.123215", props.Barcode{
Percent: 0,
Proportion: props.Proportion{
Width: 20,
Height: 2,
},
}),
text.New("5123.151231.512314.1251251.123215", props.Text{
Top: 12,
Family: "",
Style: fontstyle.Bold,
Size: 9,
Align: align.Center,
}),
),
col.New(6),
)
document, err := m.Generate()
if err != nil {
return err
return "", err
}
err = document.Save(fmt.Sprintf("invoices/%s.pdf", invoice.ID))
configDir, err := GetConfigDirectoryPath()
if err != nil {
return err
return "", err
}
err = document.GetReport().Save(fmt.Sprintf("invoices/%s.txt", invoice.ID))
invoicesDir := filepath.Join(configDir, user.Username, client.ID)
if err := os.MkdirAll(invoicesDir, os.ModePerm); err != nil {
return "", err
}
invoiceFileName := fmt.Sprintf("%s.pdf", invoice.ID)
invoicePath := filepath.Join(invoicesDir, invoiceFileName)
err = document.Save(invoicePath)
if err != nil {
return err
return "", err
}
return nil
return invoicePath, nil
}
+19
View File
@@ -1,6 +1,7 @@
package models
import (
"errors"
"time"
"github.com/google/uuid"
@@ -170,6 +171,24 @@ func (db *DB) UpdateInvoice(userId, id uuid.UUID, body types.UpdateInvoiceReques
return nil
}
func (db *DB) UpdateInvoicePDF(userId, id uuid.UUID, pdf string, invoice *types.Invoice) error {
if pdf == "" {
return errors.New("PDF path is empty!")
}
result := db.instance.Where("user_id = ?", userId).Where("id = ?", id).First(invoice)
if result.Error != nil {
return result.Error
}
result = db.instance.Model(invoice).Updates(types.Invoice{
PDF: pdf,
})
return nil
}
func (db *DB) DeleteItem(userId, id uuid.UUID) error {
result := db.instance.Unscoped().Where("user_id = ?", userId).Where("id = ?", id).Delete(&types.Item{})
+3 -3
View File
@@ -30,7 +30,7 @@ type Item struct {
UserID string `json:"userId" gorm:"type:varchar(255)"`
Name string `json:"name"`
Type string `json:"type"`
Price uint32 `json:"price"`
Price float32 `json:"price"`
Quantity uint32 `json:"quantity"`
Tax uint32 `json:"tax"`
}
@@ -79,7 +79,7 @@ type CreateItemRequestBody struct {
Name string `json:"name" validate:"alpha,required"`
Type string `json:"type" validate:"alpha,required"`
Quantity uint32 `json:"quantity" validate:"number,required"`
Price uint32 `json:"price" validate:"number,required"`
Price float32 `json:"price" validate:"number,required"`
Tax uint32 `json:"tax" validate:"number,omitempty"`
}
@@ -87,7 +87,7 @@ type UpdateItemRequestBody struct {
Name string `json:"name" validate:"alpha,omitempty"`
Type string `json:"type" validate:"alpha,omitempty"`
Quantity uint32 `json:"quantity" validate:"number,omitempty"`
Price uint32 `json:"price" validate:"number,omitempty"`
Price float32 `json:"price" validate:"number,omitempty"`
Tax uint32 `json:"tax" validate:"number,omitempty"`
}
+23
View File
@@ -0,0 +1,23 @@
-- Disable the enforcement of foreign-keys constraints
PRAGMA foreign_keys = off;
-- Create "new_items" table
CREATE TABLE `new_items` (
`id` varchar NULL,
`invoice_id` varchar NULL,
`user_id` varchar NULL,
`name` text NULL,
`type` text NULL,
`price` real NULL,
`quantity` integer NULL,
`tax` integer NULL,
PRIMARY KEY (`id`),
CONSTRAINT `fk_invoices_items` FOREIGN KEY (`invoice_id`) REFERENCES `invoices` (`id`) ON UPDATE NO ACTION ON DELETE NO ACTION
);
-- Copy rows from old table "items" to new temporary table "new_items"
INSERT INTO `new_items` (`id`, `invoice_id`, `user_id`, `name`, `type`, `price`, `quantity`, `tax`) SELECT `id`, `invoice_id`, `user_id`, `name`, `type`, `price`, `quantity`, `tax` FROM `items`;
-- Drop "items" table after copying rows
DROP TABLE `items`;
-- Rename temporary table "new_items" to "items"
ALTER TABLE `new_items` RENAME TO `items`;
-- Enable back the enforcement of foreign-keys constraints
PRAGMA foreign_keys = on;
+2 -1
View File
@@ -1,5 +1,6 @@
h1:dDD/MWKqjFIkCAuR4i/9LtH4ibg9wZR03FApuHHYCos=
h1:wDAImuaIK9lhzbnQ3++MNijLAxUnoaLdlysqqYjOfoU=
20250611102124.sql h1:HkDgtWXUxfAR9gIObTMzP98pVEIakerASGGSufW695k=
20250616102420.sql h1:7C1kEskaDwdHmQOu3t48oZcky0WaNiFeIugJcJFkjKM=
20250616150439.sql h1:nx8CvH5om7lbeEezo7roNcTV+f0agch0gBjYxKtTDn8=
20250616151658.sql h1:O4hbYFj4bwMDML0mR9PtSn7GUbXQVCbh+lGkwTNBwnU=
20250814173803.sql h1:xRBhN15wPcJJOfzzPuMoUJsUqEYNCdLmEBiYUJBF1FA=