diff --git a/server/app/admin_handler.go b/server/app/admin_handler.go index ac94d145..afbeb046 100644 --- a/server/app/admin_handler.go +++ b/server/app/admin_handler.go @@ -559,7 +559,7 @@ func (a *App) CreateNewAnnouncementHandler(req *http.Request) (interface{}, Resp for _, user := range users { subject, body := internal.AdminAnnouncementMailContent(adminAnnouncement.Subject, adminAnnouncement.Body, a.config.Server.Host, user.Name()) - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, user.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, user.Email, subject, body) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -621,7 +621,7 @@ func (a *App) SendEmailHandler(req *http.Request) (interface{}, Response) { subject, body := internal.AdminMailContent(fmt.Sprintf("Hey! 📢 %s", emailUser.Subject), emailUser.Body, a.config.Server.Host, user.Name()) - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, user.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, user.Email, subject, body) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -699,7 +699,7 @@ func (a *App) notifyAdmins() { subject, body := internal.NotifyAdminsMailContent(len(pending), a.config.Server.Host) for _, admin := range admins { - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, admin.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, admin.Email, subject, body) if err != nil { log.Error().Err(err).Send() } @@ -716,7 +716,7 @@ func (a *App) notifyAdmins() { subject, body := internal.NotifyAdminsMailLowBalanceContent(balance, a.config.Server.Host) for _, admin := range admins { - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, admin.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, admin.Email, subject, body) if err != nil { log.Error().Err(err).Send() } diff --git a/server/app/app.go b/server/app/app.go index 2d455988..77ca1311 100644 --- a/server/app/app.go +++ b/server/app/app.go @@ -26,6 +26,7 @@ type App struct { db models.DB redis streams.RedisClient deployer c4sDeployer.Deployer + mailer internal.Mailer } // NewApp creates new server app all configurations @@ -73,6 +74,7 @@ func NewApp(ctx context.Context, configFile string) (app *App, err error) { db: db, redis: redis, deployer: newDeployer, + mailer: internal.NewMailer(config.MailSender.SendGridKey), }, nil } @@ -156,6 +158,7 @@ func (a *App) registerHandlers() { invoiceRouter.HandleFunc("", WrapFunc(a.ListInvoicesHandler)).Methods("GET", "OPTIONS") invoiceRouter.HandleFunc("/{id}", WrapFunc(a.GetInvoiceHandler)).Methods("GET", "OPTIONS") + invoiceRouter.HandleFunc("/download/{id}", WrapFunc(a.DownloadInvoiceHandler)).Methods("GET", "OPTIONS") invoiceRouter.HandleFunc("/pay/{id}", WrapFunc(a.PayInvoiceHandler)).Methods("PUT", "OPTIONS") notificationRouter.HandleFunc("", WrapFunc(a.ListNotificationsHandler)).Methods("GET", "OPTIONS") diff --git a/server/app/invoice_handler.go b/server/app/invoice_handler.go index 5d1ff0af..e454966d 100644 --- a/server/app/invoice_handler.go +++ b/server/app/invoice_handler.go @@ -119,6 +119,72 @@ func (a *App) GetInvoiceHandler(req *http.Request) (interface{}, Response) { }, Ok() } +// DownloadInvoiceHandler downloads user's invoice by ID +// Example endpoint: Downloads user's invoice by ID +// @Summary Downloads user's invoice by ID +// @Description Downloads user's invoice by ID +// @Tags Invoice +// @Accept json +// @Produce json +// @Security BearerAuth +// @Param id path string true "Invoice ID" +// @Success 200 {object} Response +// @Failure 400 {object} Response +// @Failure 401 {object} Response +// @Failure 404 {object} Response +// @Failure 500 {object} Response +// @Router /invoice/download/{id} [get] +func (a *App) DownloadInvoiceHandler(req *http.Request) (interface{}, Response) { + userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) + + id, err := strconv.Atoi(mux.Vars(req)["id"]) + if err != nil { + log.Error().Err(err).Send() + return nil, BadRequest(errors.New("failed to read invoice id")) + } + + invoice, err := a.db.GetInvoice(id) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("invoice is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + // Creating pdf for invoice if it doesn't have it + if len(invoice.FileData) == 0 { + user, err := a.db.GetUserByID(userID) + if err == gorm.ErrRecordNotFound { + return nil, NotFound(errors.New("user is not found")) + } + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + pdfContent, err := internal.CreateInvoicePDF(invoice, user, a.config.InvoiceLogoPath) + if err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + + invoice.FileData = pdfContent + if err := a.db.UpdateInvoicePDF(id, invoice.FileData); err != nil { + log.Error().Err(err).Send() + return nil, InternalServerError(errors.New(internalServerErrorMsg)) + } + } + + if userID != invoice.UserID { + return nil, NotFound(errors.New("invoice is not found")) + } + + return invoice.FileData, Ok(). + WithHeader("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fmt.Sprintf("invoice-%s-%d.pdf", invoice.UserID, invoice.ID))). + WithHeader("Content-Type", "application/pdf") +} + // PayInvoiceHandler pay user's invoice // Example endpoint: Pay user's invoice // @Summary Pay user's invoice @@ -213,7 +279,7 @@ func (a *App) monthlyInvoices() { // Create invoices for all system users for _, user := range users { // 1. Create new monthly invoice - if err = a.createInvoice(user.ID.String(), now); err != nil { + if err = a.createInvoice(user, now); err != nil { log.Error().Err(err).Send() } @@ -275,15 +341,15 @@ func (a *App) monthlyInvoices() { } } -func (a *App) createInvoice(userID string, now time.Time) error { +func (a *App) createInvoice(user models.User, now time.Time) error { monthStart := time.Date(now.Year(), now.Month(), 0, 0, 0, 0, 0, time.Local) - vms, err := a.db.GetAllSuccessfulVms(userID) + vms, err := a.db.GetAllSuccessfulVms(user.ID.String()) if err != nil && err != gorm.ErrRecordNotFound { return err } - k8s, err := a.db.GetAllSuccessfulK8s(userID) + k8s, err := a.db.GetAllSuccessfulK8s(user.ID.String()) if err != nil && err != gorm.ErrRecordNotFound { return err } @@ -308,6 +374,8 @@ func (a *App) createInvoice(userID string, now time.Time) error { DeploymentResources: vm.Resources, DeploymentType: "vm", DeploymentID: vm.ID, + DeploymentName: vm.Name, + DeploymentCreatedAt: vm.CreatedAt, HasPublicIP: vm.Public, PeriodInHours: time.Since(usageStart).Hours(), Cost: cost, @@ -333,6 +401,8 @@ func (a *App) createInvoice(userID string, now time.Time) error { DeploymentResources: cluster.Master.Resources, DeploymentType: "k8s", DeploymentID: cluster.ID, + DeploymentName: cluster.Master.Name, + DeploymentCreatedAt: cluster.CreatedAt, HasPublicIP: cluster.Master.Public, PeriodInHours: time.Since(usageStart).Hours(), Cost: cost, @@ -342,11 +412,22 @@ func (a *App) createInvoice(userID string, now time.Time) error { } if len(items) > 0 { - if err = a.db.CreateInvoice(&models.Invoice{ - UserID: userID, + in := models.Invoice{ + UserID: user.ID.String(), Total: total, Deployments: items, - }); err != nil { + } + + // Creating pdf for invoice + pdfContent, err := internal.CreateInvoicePDF(in, user, a.config.InvoiceLogoPath) + if err != nil { + return err + } + + in.FileData = pdfContent + + // Creating invoice in db + if err = a.db.CreateInvoice(&in); err != nil { return err } } @@ -415,14 +496,14 @@ func (a *App) sendInvoiceReminderToUser(userID, userEmail, userName string, now } for _, invoice := range invoices { - oneMonthsAgo := now.AddDate(0, -1, 0) + // oneMonthsAgo := now.AddDate(0, -1, 0) oneWeekAgo := now.AddDate(0, 0, -7) // check if the invoice created 1 months ago (not after it) and - // last remainder sent for this invoice was 7 days ago and + // last remainder sent for this invoice was before 7 days ago and // invoice is not paid - if invoice.CreatedAt.Before(oneMonthsAgo) && - invoice.LastReminderAt.Before(oneWeekAgo) && + // invoice.CreatedAt.Before(oneMonthsAgo) && + if invoice.LastReminderAt.Before(oneWeekAgo) && !invoice.Paid { // overdue date starts after one month since invoice creation overDueStart := invoice.CreatedAt.AddDate(0, 1, 0) @@ -434,7 +515,9 @@ func (a *App) sendInvoiceReminderToUser(userID, userEmail, userName string, now mailBody := "We hope this message finds you well.\n" mailBody += fmt.Sprintf("Our records show that there is an outstanding invoice for %v %s associated with your account (%d). ", invoice.Total, currencyName, invoice.ID) - mailBody += fmt.Sprintf("As of today, the payment for this invoice is %d days overdue.", overDueDays) + if overDueDays > 0 { + mailBody += fmt.Sprintf("As of today, the payment for this invoice is %d days overdue.", overDueDays) + } mailBody += "To avoid any interruptions to your services and the potential deletion of your deployments, " mailBody += fmt.Sprintf("we kindly ask that you make the payment within the next %d days. If the invoice remains unpaid after this period, ", gracePeriod) mailBody += "please be advised that the associated deployments will be deleted from our system.\n\n" @@ -442,12 +525,17 @@ func (a *App) sendInvoiceReminderToUser(userID, userEmail, userName string, now mailBody += "You can easily pay your invoice by charging balance, activating voucher or using cards.\n\n" mailBody += "If you have already made the payment or need any assistance, " mailBody += "please don't hesitate to reach out to us.\n\n" - mailBody += "We appreciate your prompt attention to this matter and thank you fosr being a valued customer." + mailBody += "We appreciate your prompt attention to this matter and thank you for being a valued customer." subject := "Unpaid Invoice Notification – Action Required" subject, body := internal.AdminMailContent(subject, mailBody, a.config.Server.Host, userName) - if err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, userEmail, subject, body); err != nil { + if err = a.mailer.SendMail( + a.config.MailSender.Email, userEmail, subject, body, internal.Attachment{ + FileName: fmt.Sprintf("invoice-%s-%d.pdf", invoice.UserID, invoice.ID), + Data: invoice.FileData, + }, + ); err != nil { log.Error().Err(err).Send() } diff --git a/server/app/payments_handler.go b/server/app/payments_handler.go index 829590a0..9aaa84c8 100644 --- a/server/app/payments_handler.go +++ b/server/app/payments_handler.go @@ -349,6 +349,8 @@ func (a *App) DeleteCardHandler(req *http.Request) (interface{}, Response) { return nil, BadRequest(errors.New("you have active deployment and cannot delete the card")) } + // TODO: deleting vms before the end of the month then deleting all cards case + // Update the default payment method for future payments (if deleted card is the default) if card.PaymentMethodID == user.StripeDefaultPaymentID { var newPaymentMethod string diff --git a/server/app/setup.go b/server/app/setup.go index f4566ce5..3aef6fc4 100644 --- a/server/app/setup.go +++ b/server/app/setup.go @@ -76,7 +76,9 @@ func SetUp(t testing.TB) *App { "medium_vm": 20, "large_vm": 30 }, - "stripe_secret": "sk_test" + "stripe_secret": "sk_test", + "voucher_balance": 10, + "invoice_logo": "server/internal/img/logo.png" } `, dbPath) @@ -105,6 +107,7 @@ func SetUp(t testing.TB) *App { db: db, redis: streams.RedisClient{}, deployer: newDeployer, + mailer: internal.NewMailer(configuration.MailSender.SendGridKey), } return app diff --git a/server/app/user_handler.go b/server/app/user_handler.go index 5f32a936..af2aa17c 100644 --- a/server/app/user_handler.go +++ b/server/app/user_handler.go @@ -65,8 +65,7 @@ type EmailInput struct { // ApplyForVoucherInput struct for user to apply for voucher type ApplyForVoucherInput struct { - Balance uint64 `json:"balance" binding:"required" validate:"min=0"` - Reason string `json:"reason" binding:"required" validate:"nonzero"` + Reason string `json:"reason" binding:"required" validate:"nonzero"` } // AddVoucherInput struct for voucher applied by user @@ -145,7 +144,7 @@ func (a *App) SignUpHandler(req *http.Request) (interface{}, Response) { // send verification code if user is not verified or not exist code := internal.GenerateRandomCode() subject, body := internal.SignUpMailContent(code, a.config.MailSender.Timeout, fmt.Sprintf("%s %s", signUp.FirstName, signUp.LastName), a.config.Server.Host) - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, signUp.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, signUp.Email, subject, body) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -245,7 +244,7 @@ func (a *App) VerifySignUpCodeHandler(req *http.Request) (interface{}, Response) middlewares.UserCreations.WithLabelValues(user.ID.String(), user.Email).Inc() subject, body := internal.WelcomeMailContent(user.Name(), a.config.Server.Host) - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, user.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, user.Email, subject, body) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -405,7 +404,7 @@ func (a *App) ForgotPasswordHandler(req *http.Request) (interface{}, Response) { // send verification code code := internal.GenerateRandomCode() subject, body := internal.ResetPasswordMailContent(code, a.config.MailSender.Timeout, user.Name(), a.config.Server.Host) - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, email.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, email.Email, subject, body) if err != nil { log.Error().Err(err).Send() @@ -711,7 +710,7 @@ func (a *App) ApplyForVoucherHandler(req *http.Request) (interface{}, Response) voucher := models.Voucher{ Voucher: v, UserID: userID, - Balance: input.Balance, + Balance: a.config.VoucherBalance, Reason: input.Reason, } @@ -840,7 +839,6 @@ func (a *App) ActivateVoucherHandler(req *http.Request) (interface{}, Response) // @Failure 500 {object} Response // @Router /user/charge_balance [put] func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { - userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) var input ChargeBalance @@ -919,6 +917,7 @@ func (a *App) ChargeBalance(req *http.Request) (interface{}, Response) { // @Failure 500 {object} Response // @Router /user [delete] func (a *App) DeleteUserHandler(req *http.Request) (interface{}, Response) { + // TODO: delete customer from stripe userID := req.Context().Value(middlewares.UserIDKey("UserID")).(string) user, err := a.db.GetUserByID(userID) if err == gorm.ErrRecordNotFound { @@ -930,7 +929,7 @@ func (a *App) DeleteUserHandler(req *http.Request) (interface{}, Response) { } // 1. Create last invoice to pay if there were active deployments - if err := a.createInvoice(userID, time.Now()); err != nil { + if err := a.createInvoice(user, time.Now()); err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) } diff --git a/server/app/user_handler_test.go b/server/app/user_handler_test.go index 3e74685f..0720441b 100644 --- a/server/app/user_handler_test.go +++ b/server/app/user_handler_test.go @@ -870,8 +870,6 @@ func TestApplyForVoucherHandler(t *testing.T) { assert.NoError(t, err) voucherBody := []byte(`{ - "vms":10, - "public_ips":1, "reason":"strongReason" }`) diff --git a/server/app/voucher_handler.go b/server/app/voucher_handler.go index ab65066c..eb6a58fb 100644 --- a/server/app/voucher_handler.go +++ b/server/app/voucher_handler.go @@ -176,7 +176,7 @@ func (a *App) UpdateVoucherHandler(req *http.Request) (interface{}, Response) { subject, body = internal.RejectedVoucherMailContent(user.Name(), a.config.Server.Host) } - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, user.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, user.Email, subject, body) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) @@ -228,7 +228,7 @@ func (a *App) ApproveAllVouchersHandler(req *http.Request) (interface{}, Respons } subject, body := internal.ApprovedVoucherMailContent(v.Voucher, user.Name(), a.config.Server.Host) - err = internal.SendMail(a.config.MailSender.Email, a.config.MailSender.SendGridKey, user.Email, subject, body) + err = a.mailer.SendMail(a.config.MailSender.Email, user.Email, subject, body) if err != nil { log.Error().Err(err).Send() return nil, InternalServerError(errors.New(internalServerErrorMsg)) diff --git a/server/app/wrapper.go b/server/app/wrapper.go index 69d0bc42..7c34e4cc 100644 --- a/server/app/wrapper.go +++ b/server/app/wrapper.go @@ -69,7 +69,11 @@ func WrapFunc(a Handler) http.HandlerFunc { status = result.Status() } - if err := json.NewEncoder(w).Encode(object); err != nil { + if bytes, ok := object.([]byte); ok { + if _, err := w.Write(bytes); err != nil { + log.Error().Err(err).Msg("failed to write return object") + } + } else if err := json.NewEncoder(w).Encode(object); err != nil { log.Error().Err(err).Msg("failed to encode return object") } middlewares.Requests.WithLabelValues(r.Method, r.RequestURI, fmt.Sprint(status)).Inc() diff --git a/server/docs/docs.go b/server/docs/docs.go index 67d8ea7f..584bfe93 100644 --- a/server/docs/docs.go +++ b/server/docs/docs.go @@ -447,6 +447,57 @@ const docTemplate = `{ } } }, + "/invoice/download/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Downloads user's invoice by ID", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Invoice" + ], + "summary": "Downloads user's invoice by ID", + "parameters": [ + { + "type": "string", + "description": "Invoice ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": {} + }, + "400": { + "description": "Bad Request", + "schema": {} + }, + "401": { + "description": "Unauthorized", + "schema": {} + }, + "404": { + "description": "Not Found", + "schema": {} + }, + "500": { + "description": "Internal Server Error", + "schema": {} + } + } + } + }, "/invoice/pay/{id}": { "put": { "security": [ @@ -2712,14 +2763,9 @@ const docTemplate = `{ "app.ApplyForVoucherInput": { "type": "object", "required": [ - "balance", "reason" ], "properties": { - "balance": { - "type": "integer", - "minimum": 0 - }, "reason": { "type": "string" } @@ -3207,9 +3253,15 @@ const docTemplate = `{ "cost": { "type": "number" }, + "deployment_created_at": { + "type": "string" + }, "deployment_id": { "type": "integer" }, + "deployment_name": { + "type": "string" + }, "has_public_ip": { "type": "boolean" }, @@ -3256,10 +3308,16 @@ const docTemplate = `{ "$ref": "#/definitions/models.DeploymentItem" } }, + "file_data": { + "type": "array", + "items": { + "type": "integer" + } + }, "id": { "type": "integer" }, - "last_remainder_at": { + "last_reminder_at": { "type": "string" }, "paid": { diff --git a/server/docs/swagger.yaml b/server/docs/swagger.yaml index c93889ae..f3bf687d 100644 --- a/server/docs/swagger.yaml +++ b/server/docs/swagger.yaml @@ -35,13 +35,9 @@ definitions: type: object app.ApplyForVoucherInput: properties: - balance: - minimum: 0 - type: integer reason: type: string required: - - balance - reason type: object app.ChangePasswordInput: @@ -373,8 +369,12 @@ definitions: properties: cost: type: number + deployment_created_at: + type: string deployment_id: type: integer + deployment_name: + type: string has_public_ip: type: boolean id: @@ -403,9 +403,13 @@ definitions: items: $ref: '#/definitions/models.DeploymentItem' type: array + file_data: + items: + type: integer + type: array id: type: integer - last_remainder_at: + last_reminder_at: type: string paid: type: boolean @@ -993,6 +997,40 @@ paths: summary: List all invoices tags: - Admin + /invoice/download/{id}: + get: + consumes: + - application/json + description: Downloads user's invoice by ID + parameters: + - description: Invoice ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: {} + "400": + description: Bad Request + schema: {} + "401": + description: Unauthorized + schema: {} + "404": + description: Not Found + schema: {} + "500": + description: Internal Server Error + schema: {} + security: + - BearerAuth: [] + summary: Downloads user's invoice by ID + tags: + - Invoice /invoice/pay/{id}: put: consumes: diff --git a/server/go.mod b/server/go.mod index b8ad0fa7..694342ee 100644 --- a/server/go.mod +++ b/server/go.mod @@ -15,6 +15,7 @@ require ( github.com/prometheus/client_golang v1.20.5 github.com/rs/zerolog v1.33.0 github.com/sendgrid/sendgrid-go v3.16.0+incompatible + github.com/signintech/gopdf v0.29.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.10.0 github.com/stripe/stripe-go/v81 v81.1.1 @@ -71,6 +72,7 @@ require ( github.com/mimoo/StrobeGo v0.0.0-20220103164710-9a04d6ca976b // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/onsi/gomega v1.34.2 // indirect + github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 // indirect github.com/pierrec/xxHash v0.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_model v0.6.1 // indirect diff --git a/server/go.sum b/server/go.sum index fb46f78b..1a3e3b89 100644 --- a/server/go.sum +++ b/server/go.sum @@ -137,8 +137,11 @@ github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/gomega v1.34.2 h1:pNCwDkzrsv7MS9kpaQvVb1aVLahQXyJ/Tv5oAZMI3i8= github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311 h1:zyWXQ6vu27ETMpYsEMAsisQ+GqJ4e1TPvSNfdOPF0no= +github.com/phpdave11/gofpdi v1.0.14-0.20211212211723-1f10f9844311/go.mod h1:vBmVV0Do6hSBHC8uKUQ71JGW+ZGQq74llk/7bXwjDoI= github.com/pierrec/xxHash v0.1.5 h1:n/jBpwTHiER4xYvK3/CdPVnLDPchj8eTJFFLUb4QHBo= github.com/pierrec/xxHash v0.1.5/go.mod h1:w2waW5Zoa/Wc4Yqe0wgrIYAGKqRMf7czn2HNKXmuL+I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -166,6 +169,8 @@ github.com/sendgrid/sendgrid-go v3.16.0+incompatible h1:i8eE6IMkiCy7vusSdacHHSBU github.com/sendgrid/sendgrid-go v3.16.0+incompatible/go.mod h1:QRQt+LX/NmgVEvmdRw0VT/QgUn499+iza2FnDca9fg8= github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= github.com/shirou/gopsutil v3.21.11+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA= +github.com/signintech/gopdf v0.29.0 h1:ZwnHKvdgBtl1C2DUmbC9a29RCtQTehb11v/Z9w8xb3s= +github.com/signintech/gopdf v0.29.0/go.mod h1:d23eO35GpEliSrF22eJ4bsM3wVeQJTjXTHq5x5qGKjA= github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= diff --git a/server/internal/config_parser.go b/server/internal/config_parser.go index ae951709..007f8ff0 100644 --- a/server/internal/config_parser.go +++ b/server/internal/config_parser.go @@ -24,6 +24,8 @@ type Configuration struct { PricesPerMonth Prices `json:"prices"` Currency string `json:"currency" validate:"nonzero"` StripeSecret string `json:"stripe_secret" validate:"nonzero"` + VoucherBalance uint64 `json:"voucher_balance" validate:"nonzero"` + InvoiceLogoPath string `json:"invoice_logo" validate:"nonzero"` } // Server struct to hold server's information diff --git a/server/internal/config_parser_test.go b/server/internal/config_parser_test.go index b640280e..cf248900 100644 --- a/server/internal/config_parser_test.go +++ b/server/internal/config_parser_test.go @@ -42,7 +42,9 @@ var rightConfig = ` "medium_vm": 20, "large_vm": 30 }, - "stripe_secret": "sk_test" + "stripe_secret": "sk_test", + "voucher_balance": 10, + "invoice_logo": "server/internal/img/logo.png" } ` diff --git a/server/internal/email_sender.go b/server/internal/email_sender.go index b8ec2c08..bc1ada62 100644 --- a/server/internal/email_sender.go +++ b/server/internal/email_sender.go @@ -3,6 +3,7 @@ package internal import ( _ "embed" + "encoding/base64" "fmt" "strings" @@ -39,8 +40,23 @@ var ( adminAnnouncement []byte ) +type Mailer struct { + client *sendgrid.Client +} + +type Attachment struct { + FileName string + Data []byte +} + +func NewMailer(sendGridKey string) Mailer { + return Mailer{ + client: sendgrid.NewSendClient(sendGridKey), + } +} + // SendMail sends verification mails -func SendMail(sender, sendGridKey, receiver, subject, body string) error { +func (m *Mailer) SendMail(sender, receiver, subject, body string, attachments ...Attachment) error { from := mail.NewEmail("Cloud4All", sender) err := validators.ValidMail(receiver) @@ -51,9 +67,17 @@ func SendMail(sender, sendGridKey, receiver, subject, body string) error { to := mail.NewEmail("Cloud4All User", receiver) message := mail.NewSingleEmail(from, subject, to, "", body) - client := sendgrid.NewSendClient(sendGridKey) - _, err = client.Send(message) + if len(attachments) > 0 { + attachment := mail.NewAttachment() + attachment = attachment.SetContent(base64.StdEncoding.EncodeToString(attachments[0].Data)) + attachment = attachment.SetType("application/pdf") + attachment = attachment.SetFilename(attachments[0].FileName) + attachment = attachment.SetDisposition("attachment") + message = message.AddAttachment(attachment) + } + + _, err = m.client.Send(message) return err } diff --git a/server/internal/email_sender_test.go b/server/internal/email_sender_test.go index 8d424d64..253ffa35 100644 --- a/server/internal/email_sender_test.go +++ b/server/internal/email_sender_test.go @@ -11,13 +11,15 @@ import ( ) func TestSendMail(t *testing.T) { + m := NewMailer("1234") + t.Run("send valid mail", func(t *testing.T) { - err := SendMail("sender@gmail.com", "1234", "receiver@gmail.com", "subject", "body") + err := m.SendMail("sender@gmail.com", "receiver@gmail.com", "subject", "body") assert.NoError(t, err) }) t.Run("send invalid mail", func(t *testing.T) { - err := SendMail("sender@gmail.com", "1234", "receiver", "subject", "body") + err := m.SendMail("sender@gmail.com", "receiver", "subject", "body") assert.Error(t, err) }) } diff --git a/server/internal/fonts/Arial-Bold.ttf b/server/internal/fonts/Arial-Bold.ttf new file mode 100644 index 00000000..a6037e68 Binary files /dev/null and b/server/internal/fonts/Arial-Bold.ttf differ diff --git a/server/internal/fonts/Arial-Italic.ttf b/server/internal/fonts/Arial-Italic.ttf new file mode 100644 index 00000000..38019978 Binary files /dev/null and b/server/internal/fonts/Arial-Italic.ttf differ diff --git a/server/internal/fonts/Arial.ttf b/server/internal/fonts/Arial.ttf new file mode 100644 index 00000000..8682d946 Binary files /dev/null and b/server/internal/fonts/Arial.ttf differ diff --git a/server/internal/img/logo.png b/server/internal/img/logo.png new file mode 100644 index 00000000..7aed89c9 Binary files /dev/null and b/server/internal/img/logo.png differ diff --git a/server/internal/pdf_generator.go b/server/internal/pdf_generator.go new file mode 100644 index 00000000..591bb6f2 --- /dev/null +++ b/server/internal/pdf_generator.go @@ -0,0 +1,473 @@ +package internal + +import ( + "fmt" + "strconv" + "time" + + "github.com/codescalers/cloud4students/models" + "github.com/pkg/errors" + "github.com/signintech/gopdf" +) + +const ( + greyColor uint8 = 80 + darkGreyColor uint8 = 60 + + startX float64 = 25 + startY float64 = 30 + + fontPath = "internal/fonts/Arial.ttf" + boldFontPath = "internal/fonts/Arial-Bold.ttf" + italicFontPath = "internal/fonts/Arial-Italic.ttf" +) + +type InvoicePDF struct { + invoice models.Invoice + user models.User + + pdf *gopdf.GoPdf + config gopdf.Config + startX float64 + startY float64 + logoPath string +} + +func CreateInvoicePDF( + invoice models.Invoice, user models.User, logoPath string, +) ([]byte, error) { + pdf := gopdf.GoPdf{} + config := gopdf.Config{PageSize: *gopdf.PageSizeA4} + pdf.Start(config) + + invoicePDF := InvoicePDF{ + invoice: invoice, + user: user, + pdf: &pdf, + config: config, + startX: startX, + startY: startY, + logoPath: logoPath, + } + + if err := invoicePDF.setFonts(); err != nil { + return nil, errors.Wrap(err, "failed to set fonts") + } + + if err := invoicePDF.draw(); err != nil { + return nil, errors.Wrap(err, "failed to draw pdf") + } + + return pdf.GetBytesPdf(), nil +} + +func (in *InvoicePDF) setFonts() error { + if err := in.pdf.AddTTFFont("Arial", fontPath); err != nil { + return err + } + + if err := in.pdf.AddTTFFont("Arial-Bold", boldFontPath); err != nil { + return err + } + + return in.pdf.AddTTFFont("Arial-Italic", italicFontPath) +} + +func (in *InvoicePDF) draw() error { + in.pdf.AddPage() + + if err := in.setLogo(); err != nil { + return errors.Wrap(err, "failed to set logo") + } + + // space + in.startY += 35 + + if err := in.title(); err != nil { + return errors.Wrap(err, "failed to display title") + } + + // space + in.startY += 45 + + if err := in.companySection(); err != nil { + return errors.Wrap(err, "failed to display company section") + } + + if err := in.invoiceSection(); err != nil { + return errors.Wrap(err, "failed to display invoice section") + } + + // space + in.startY += 70 + + if err := in.userDetails(); err != nil { + return errors.Wrap(err, "failed to display user section") + } + + // space + in.startY += 90 + + if err := in.summary(); err != nil { + return errors.Wrap(err, "failed to display summary") + } + + // space + in.startY += 70 + + // Total due + if err := in.totalDue(); err != nil { + return errors.Wrap(err, "failed to display total due") + } + + // space + in.startY += 85 + + // Product usage charges + if err := in.usageCharges(); err != nil { + return errors.Wrap(err, "failed to display usage charges") + } + + // space + in.startY += 85 + + // Table Header + if err := in.tableHeader(); err != nil { + return errors.Wrap(err, "failed to display table header") + } + + // space + in.startY += 30 + + // Table content + if err := in.tableContent(); err != nil { + return errors.Wrap(err, "failed to display table content") + } + + return nil +} + +func (in *InvoicePDF) setLogo() error { + return in.pdf.Image(in.logoPath, in.startX, in.startY, nil) +} + +func (in *InvoicePDF) title() error { + if err := in.pdf.SetFont("Arial", "", 14); err != nil { + return err + } + + in.pdf.SetTextColor(greyColor, greyColor, greyColor) + in.pdf.SetXY(in.startX, in.startY) + + return in.pdf.Cell(nil, + fmt.Sprintf("Final invoice for %s %d billing period", in.invoice.CreatedAt.Month().String(), in.invoice.CreatedAt.Year()), + ) +} + +func (in *InvoicePDF) companySection() error { + if err := in.pdf.SetFont("Arial-Bold", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(darkGreyColor, darkGreyColor, darkGreyColor) + + in.pdf.SetXY(in.startX, in.startY) + if err := in.pdf.Cell(nil, "From"); err != nil { + return err + } + + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(greyColor, greyColor, greyColor) + + in.pdf.SetXY(in.startX, in.startY+15) + if err := in.pdf.Cell(nil, "Codescalers Egypt"); err != nil { + return err + } + + in.pdf.SetXY(in.startX, in.startY+27) + if err := in.pdf.Cell(nil, "9 Al Wardi street, El Hegaz St"); err != nil { + return err + } + + in.pdf.SetXY(in.startX, in.startY+39) + return in.pdf.Cell(nil, "Cairo Governorate 11341") +} + +func (in *InvoicePDF) invoiceSection() error { + marginRight := float64(250) + + if err := in.pdf.SetFont("Arial-Bold", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(darkGreyColor, darkGreyColor, darkGreyColor) + + in.pdf.SetXY(in.startX+marginRight, in.startY) + if err := in.pdf.Cell(nil, "Details"); err != nil { + return err + } + + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(greyColor, greyColor, greyColor) + + // Data labels + in.pdf.SetXY(in.startX+250, in.startY+15) + if err := in.pdf.Cell(nil, "Invoice number:"); err != nil { + return err + } + in.pdf.SetXY(in.startX+250, in.startY+30) + if err := in.pdf.Cell(nil, "Date of issue:"); err != nil { + return err + } + in.pdf.SetXY(in.startX+250, in.startY+45) + if err := in.pdf.Cell(nil, "Payment due on:"); err != nil { + return err + } + + // Data details + textWidth, err := in.pdf.MeasureTextWidth(fmt.Sprint(in.invoice.ID)) + if err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-textWidth, in.startY+20) + if err := in.pdf.Cell(nil, fmt.Sprint(in.invoice.ID)); err != nil { + return err + } + + textWidth, err = in.pdf.MeasureTextWidth(in.invoice.CreatedAt.Format("January 2, 2006")) + if err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-textWidth, in.startY+35) + if err := in.pdf.Cell(nil, in.invoice.CreatedAt.Format("January 2, 2006")); err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-textWidth, in.startY+50) + return in.pdf.Cell(nil, in.invoice.CreatedAt.Format("January 2, 2006")) +} + +func (in *InvoicePDF) userDetails() error { + if err := in.pdf.SetFont("Arial-Bold", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(darkGreyColor, darkGreyColor, darkGreyColor) + + in.pdf.SetXY(in.startX, in.startY) + if err := in.pdf.Cell(nil, "For"); err != nil { + return err + } + + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(greyColor, greyColor, greyColor) + + // name + in.pdf.SetXY(in.startX, in.startY+15) + if err := in.pdf.Cell(nil, fmt.Sprintf("%s %s", in.user.FirstName, in.user.LastName)); err != nil { + return err + } + + // email + in.pdf.SetXY(in.startX, in.startY+27) + return in.pdf.Cell(nil, fmt.Sprintf("<%s>", in.user.Email)) +} + +func (in *InvoicePDF) summary() error { + if err := in.pdf.SetFont("Arial", "", 14); err != nil { + return err + } + in.pdf.SetXY(in.startX, in.startY) + if err := in.pdf.Cell(nil, "Summary"); err != nil { + return err + } + + in.pdf.Line(in.startX, in.startY+25, in.startX+540, in.startY+25) + in.pdf.Line(in.startX, in.startY+55, in.startX+540, in.startY+55) + + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + + in.pdf.SetXY(in.startX, in.startY+35) + if err := in.pdf.Cell(nil, "Total usage charges"); err != nil { + return err + } + + totalText := formatFloat(in.invoice.Total) + totalTextWidth, err := in.pdf.MeasureTextWidth(totalText) + if err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-totalTextWidth, in.startY+35) + return in.pdf.Cell(nil, formatFloat(in.invoice.Total)) +} + +func (in *InvoicePDF) totalDue() error { + if err := in.pdf.SetFont("Arial-Bold", "", 14); err != nil { + return err + } + + in.pdf.SetXY(in.startX, in.startY) + if err := in.pdf.Cell(nil, "Total due"); err != nil { + return err + } + + totalText := formatFloat(in.invoice.Total) + totalTextWidth, err := in.pdf.MeasureTextWidth(totalText) + if err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-totalTextWidth, in.startY) + if err := in.pdf.Cell(nil, formatFloat(in.invoice.Total)); err != nil { + return err + } + + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + in.pdf.SetXY(in.startX, in.startY+25) + if err := in.pdf.Cell(nil, "If you have a credit card on your account, it will be automatically charged within 24 hours"); err != nil { + return err + } + + in.pdf.SetStrokeColor(200, 200, 200) + in.pdf.Line(in.startX, in.startY+60, in.startX+540, in.startY+60) + + return nil +} + +func (in *InvoicePDF) usageCharges() error { + if err := in.pdf.SetFont("Arial", "", 14); err != nil { + return err + } + in.pdf.SetXY(in.startX, in.startY) + if err := in.pdf.Cell(nil, "Product usage charges"); err != nil { + return err + } + + if err := in.pdf.SetFont("Arial-Italic", "", 10); err != nil { + return err + } + + in.pdf.SetXY(in.startX, in.startY+20) + return in.pdf.Cell(nil, "Detailed usage information ca n be downloaded from the invoices section of your account") +} + +func (in *InvoicePDF) tableHeader() error { + if err := in.pdf.SetFont("Arial-Bold", "", 10); err != nil { + return err + } + + in.pdf.SetTextColor(darkGreyColor, darkGreyColor, darkGreyColor) + in.pdf.SetXY(in.startX, in.startY) + if err := in.pdf.Cell(nil, "Virtual machines"); err != nil { + return err + } + + in.pdf.SetXY(in.startX+250, in.startY) + if err := in.pdf.Cell(nil, "Hours"); err != nil { + return err + } + + in.pdf.SetXY(in.startX+300, in.startY) + if err := in.pdf.Cell(nil, "Start"); err != nil { + return err + } + + in.pdf.SetXY(in.startX+380, in.startY) + if err := in.pdf.Cell(nil, "End"); err != nil { + return err + } + + totalText := formatFloat(in.invoice.Total) + totalTextWidth, err := in.pdf.MeasureTextWidth(totalText) + if err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-totalTextWidth, in.startY) + if err := in.pdf.Cell(nil, formatFloat(in.invoice.Total)); err != nil { + return err + } + + in.pdf.SetStrokeColor(darkGreyColor, darkGreyColor, darkGreyColor) + in.pdf.Line(in.startX, in.startY+15, in.startX+540, in.startY+15) + return nil +} + +func (in *InvoicePDF) tableContent() error { + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(greyColor, greyColor, greyColor) + + y := in.startY + for _, d := range in.invoice.Deployments { + in.pdf.SetXY(in.startX, y) + if err := in.pdf.Cell(nil, fmt.Sprintf("vm-%s-%s", d.DeploymentName, d.DeploymentResources)); err != nil { + return err + } + + in.pdf.SetXY(in.startX+250, y) + if err := in.pdf.Cell(nil, fmt.Sprint(d.PeriodInHours)); err != nil { + return err + } + + in.pdf.SetXY(in.startX+300, y) + if err := in.pdf.Cell(nil, d.DeploymentCreatedAt.Format("01-02 15:04")); err != nil { + return err + } + + in.pdf.SetXY(in.startX+380, y) + if err := in.pdf.Cell(nil, time.Now().Format("01-02 15:04")); err != nil { + return err + } + + costTextWidth, err := in.pdf.MeasureTextWidth(formatFloat(d.Cost)) + if err != nil { + return err + } + + in.pdf.SetXY(in.startX+540-costTextWidth, y) + if err := in.pdf.Cell(nil, formatFloat(d.Cost)); err != nil { + return err + } + + if y > in.config.PageSize.H-50 { + in.pdf.AddPage() + + in.startY = startY + y = in.startY + if err := in.tableHeader(); err != nil { + return err + } + + y += 10 + if err := in.pdf.SetFont("Arial", "", 10); err != nil { + return err + } + in.pdf.SetTextColor(greyColor, greyColor, greyColor) + } + + y += 15 + } + + return nil +} + +func formatFloat(f float64) string { + // Check if the number has a fractional part + if f == float64(int(f)) { + return strconv.Itoa(int(f)) + } + + return fmt.Sprintf("%.2f", f) +} diff --git a/server/models/invoice.go b/server/models/invoice.go index 1ccfb7df..188d3785 100644 --- a/server/models/invoice.go +++ b/server/models/invoice.go @@ -8,27 +8,30 @@ import ( type Invoice struct { ID int `json:"id" gorm:"primaryKey"` - UserID string `json:"user_id" binding:"required"` + UserID string `json:"user_id" binding:"required"` Total float64 `json:"total"` Deployments []DeploymentItem `json:"deployments" gorm:"foreignKey:invoice_id"` // TODO: Tax float64 `json:"tax"` Paid bool `json:"paid"` PaymentDetails PaymentDetails `json:"payment_details" gorm:"foreignKey:invoice_id"` - LastReminderAt time.Time `json:"last_remainder_at"` + LastReminderAt time.Time `json:"last_reminder_at"` CreatedAt time.Time `json:"created_at"` PaidAt time.Time `json:"paid_at"` + FileData []byte `json:"file_data" gorm:"type:blob"` } type DeploymentItem struct { - ID int `json:"id" gorm:"primaryKey"` - InvoiceID int `json:"invoice_id"` - DeploymentID int `json:"deployment_id"` - DeploymentType string `json:"type"` - DeploymentResources string `json:"resources"` - HasPublicIP bool `json:"has_public_ip"` - PeriodInHours float64 `json:"period"` - Cost float64 `json:"cost"` + ID int `json:"id" gorm:"primaryKey"` + InvoiceID int `json:"invoice_id"` + DeploymentID int `json:"deployment_id"` + DeploymentName string `json:"deployment_name"` + DeploymentCreatedAt time.Time `json:"deployment_created_at"` + DeploymentType string `json:"type"` + DeploymentResources string `json:"resources"` + HasPublicIP bool `json:"has_public_ip"` + PeriodInHours float64 `json:"period"` + Cost float64 `json:"cost"` } type PaymentDetails struct { @@ -69,7 +72,11 @@ func (d *DB) ListUnpaidInvoices(userID string) ([]Invoice, error) { } func (d *DB) UpdateInvoiceLastRemainderDate(id int) error { - return d.db.Model(&Invoice{}).Where("id = ?", id).Updates(map[string]interface{}{"last_remainder_at": time.Now()}).Error + return d.db.Model(&Invoice{}).Where("id = ?", id).Updates(map[string]interface{}{"last_reminder_at": time.Now()}).Error +} + +func (d *DB) UpdateInvoicePDF(id int, data []byte) error { + return d.db.Model(&Invoice{}).Where("id = ?", id).Updates(map[string]interface{}{"file_data": data}).Error } // PayInvoice updates paid with true and paid at field with current time in the invoice