diff --git a/service/emailService.go b/service/emailService.go index 883ba48..71f5182 100644 --- a/service/emailService.go +++ b/service/emailService.go @@ -2,6 +2,7 @@ package service import ( "bytes" + "encoding/base64" "errors" "fmt" "time" @@ -49,12 +50,19 @@ type EmailSendResponse struct { } type EmailMessage struct { - From string `json:"From"` - To string `json:"To"` - Subject string `json:"Subject"` - TextBody string `json:"TextBody"` - HtmlBody string `json:"HtmlBody"` - MessageStream string `json:"MessageStream"` + From string `json:"From"` + To string `json:"To"` + Subject string `json:"Subject"` + TextBody string `json:"TextBody"` + HtmlBody string `json:"HtmlBody"` + MessageStream string `json:"MessageStream"` + Attachments []EmailAttachment `json:"Attachments,omitempty"` +} + +type EmailAttachment struct { + Name string `json:"Name"` + Content string `json:"Content"` + ContentType string `json:"ContentType"` } func SendNewsEmail(email []string, subject, htmlBody string) error { @@ -162,7 +170,7 @@ func SendAccountResettedEmail(email string) error { return callSendEmail(email, subjectEmailAccountResetted, body.String()) } -func SendNodeOwnerDraftEmail(email string) error { +func SendNodeOwnerDraftEmail(email string, attachments ...EmailAttachment) error { template, err := templates.GetOperatorDraftTemplate() if err != nil { return errors.New("error while retrieving email template: " + err.Error()) @@ -173,7 +181,7 @@ func SendNodeOwnerDraftEmail(email string) error { if err != nil { return errors.New("error while executing email template: " + err.Error()) } - return callSendEmail(email, subjectNewInvoiceDraft, body.String()) + return callSendEmailWithAttachments(email, subjectNewInvoiceDraft, body.String(), attachments) } func SendCspDraftEmail(email string) error { @@ -350,6 +358,10 @@ func callSendBatchEmail(emails []string, subject, htmlBody string) error { } func callSendEmail(email, subject, htmlBody string) error { + return callSendEmailWithAttachments(email, subject, htmlBody, nil) +} + +func callSendEmailWithAttachments(email, subject, htmlBody string, attachments []EmailAttachment) error { msg := EmailMessage{ From: config.Config.Mail.FromEmail, To: email, @@ -357,6 +369,7 @@ func callSendEmail(email, subject, htmlBody string) error { TextBody: "", HtmlBody: htmlBody, MessageStream: messageStreamSend, + Attachments: attachments, } var resp EmailSendResponse @@ -372,6 +385,14 @@ func callSendEmail(email, subject, htmlBody string) error { return nil } +func newEmailAttachment(name, contentType string, content []byte) EmailAttachment { + return EmailAttachment{ + Name: name, + Content: base64.StdEncoding.EncodeToString(content), + ContentType: contentType, + } +} + func confirmUrl(t string) string { return fmt.Sprintf(config.Config.Mail.ConfirmUrl, t) } diff --git a/service/monthlyPoaiInvoiceService.go b/service/monthlyPoaiInvoiceService.go index bebc793..53e5fb8 100644 --- a/service/monthlyPoaiInvoiceService.go +++ b/service/monthlyPoaiInvoiceService.go @@ -5,6 +5,7 @@ import ( "math/big" "strings" "time" + "unicode" "github.com/NaeuralEdgeProtocol/ratio1-backend/model" "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" @@ -122,23 +123,124 @@ func MonthlyPoaiInvoiceReport() { drafts = append(drafts, invoice) } - allCSP := make(map[string]bool) //map[email]true to have unique emails - allNodeOwner := make(map[string]bool) + cspEmails := make(map[string][]string) + nodeOwnerDrafts := make(map[string][]model.InvoiceDraft) for _, invoice := range drafts { if invoice.UserAddress != invoice.CspOwner { // I should not receive emails if i worked on my nodes - allNodeOwner[invoice.UserProfile.Email] = true - allCSP[invoice.CspProfile.Email] = true + nodeOwnerDrafts[invoice.UserAddress] = append(nodeOwnerDrafts[invoice.UserAddress], invoice) + if _, found := cspEmails[invoice.CspOwner]; !found { + cspEmails[invoice.CspOwner] = draftNotificationEmails(invoice.CspOwner, invoice.CspProfile.Email) + } } } //send unique email for csp and node owner ( even if they have more than 1 invoice) - for k := range allNodeOwner { - _ = SendNodeOwnerDraftEmail(k) //! doesn't check error + for address, invoices := range nodeOwnerDrafts { + attachments, err := draftInvoiceAttachments(invoices) + if err != nil { + fmt.Println("error while generating draft invoice attachments: " + err.Error()) + continue + } + for _, email := range draftNotificationEmails(address, invoices[0].UserProfile.Email) { + _ = SendNodeOwnerDraftEmail(email, attachments...) //! doesn't check error + } + } + + for _, emails := range cspEmails { + for _, email := range emails { + _ = SendCspDraftEmail(email) //! doesn't check error + } + } +} + +func draftNotificationEmails(address, fallbackEmail string) []string { + emails, err := getConfirmedAccountEmails(address) + if err != nil { + fmt.Println("error while retrieving draft notification emails: " + err.Error()) + return nil + } + if len(emails) > 0 { + return emails + } + + email := TrimWhitespacesAndToLower(fallbackEmail) + if email == "" { + return nil + } + return []string{email} +} + +func draftInvoiceAttachments(drafts []model.InvoiceDraft) ([]EmailAttachment, error) { + attachments := make([]EmailAttachment, 0, len(drafts)) + for _, draft := range drafts { + allocations, err := storage.GetAllocationsByDraftId(draft.DraftId.String()) + if err != nil { + return nil, err + } + content, err := FillInvoiceDraftTemplate(draft, allocations) + if err != nil { + return nil, err + } + attachments = append(attachments, newEmailAttachment(draftInvoiceAttachmentName(draft, ".doc"), "application/msword", content)) + } + return attachments, nil +} + +func draftInvoiceAttachmentName(draft model.InvoiceDraft, ext string) string { + if ext == "" { + ext = ".doc" + } + if ext[0] != '.' { + ext = "." + ext + } + + supplier, ok := draft.UserProfile.GetNameAsString() + if !ok { + supplier = draft.UserAddress + } + beneficiary, ok := draft.CspProfile.GetNameAsString() + if !ok { + beneficiary = draft.CspOwner + } + invoiceNumber := fmt.Sprintf("%d", draft.InvoiceNumber) + if strings.TrimSpace(draft.InvoiceSeries) != "" { + invoiceNumber += "-" + draft.InvoiceSeries + } + + return fmt.Sprintf("%s_%s_%s_%s%s", + draft.CreationTimestamp.Format("200601"), + safeFileNamePart(supplier), + safeFileNamePart(beneficiary), + safeFileNamePart(invoiceNumber), + ext, + ) +} + +func safeFileNamePart(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "unknown" + } + + var out strings.Builder + lastDash := false + for _, r := range value { + if unicode.IsLetter(r) || unicode.IsDigit(r) { + out.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + out.WriteByte('-') + lastDash = true + } } - for k := range allCSP { - _ = SendCspDraftEmail(k) //! doesn't check error + name := strings.Trim(out.String(), "-") + if name == "" { + return "unknown" } + return name } func formKey(address1, address2 string) string { diff --git a/service/monthlyPoaiInvoiceService_test.go b/service/monthlyPoaiInvoiceService_test.go index 111ef75..d90d72e 100644 --- a/service/monthlyPoaiInvoiceService_test.go +++ b/service/monthlyPoaiInvoiceService_test.go @@ -1,12 +1,79 @@ package service import ( + "errors" "testing" + "time" "github.com/NaeuralEdgeProtocol/ratio1-backend/config" + "github.com/NaeuralEdgeProtocol/ratio1-backend/model" "github.com/NaeuralEdgeProtocol/ratio1-backend/storage" + "github.com/stretchr/testify/require" ) +func TestDraftNotificationEmailsUsesConfirmedPrimaryAndSecondary(t *testing.T) { + previousGetAccount := getAccountByAddressFn + previousGetNotificationEmail := getNotificationEmailFn + defer func() { + getAccountByAddressFn = previousGetAccount + getNotificationEmailFn = previousGetNotificationEmail + }() + + primaryEmail := "Owner@Example.com" + secondaryEmail := " Ops@Example.com " + getAccountByAddressFn = func(address string) (*model.Account, bool, error) { + require.Equal(t, "0xowner", address) + return &model.Account{ + Address: address, + Email: &primaryEmail, + EmailConfirmed: true, + }, true, nil + } + getNotificationEmailFn = func(address string) (*model.AccountNotificationEmail, bool, error) { + require.Equal(t, "0xowner", address) + return &model.AccountNotificationEmail{ + AccountAddress: address, + Email: &secondaryEmail, + EmailConfirmed: true, + }, true, nil + } + + require.Equal(t, []string{"owner@example.com", "ops@example.com"}, draftNotificationEmails("0xowner", "fallback@example.com")) +} + +func TestDraftNotificationEmailsSkipsFallbackWhenAccountLookupFails(t *testing.T) { + previousGetAccount := getAccountByAddressFn + defer func() { + getAccountByAddressFn = previousGetAccount + }() + + getAccountByAddressFn = func(address string) (*model.Account, bool, error) { + return nil, false, errors.New("storage unavailable") + } + + require.Nil(t, draftNotificationEmails("0xowner", "fallback@example.com")) +} + +func TestDraftInvoiceAttachmentName(t *testing.T) { + supplier := "Acme Nodes SRL" + beneficiary := "Ratio/Cloud:EU" + draft := model.InvoiceDraft{ + CreationTimestamp: time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC), + InvoiceNumber: 42, + InvoiceSeries: "NODE", + UserProfile: model.UserInfo{ + CompanyName: &supplier, + IsCompany: true, + }, + CspProfile: model.UserInfo{ + CompanyName: &beneficiary, + IsCompany: true, + }, + } + + require.Equal(t, "202607_Acme-Nodes-SRL_Ratio-Cloud-EU_42-NODE.doc", draftInvoiceAttachmentName(draft, "doc")) +} + func Test_monthlyPoaiInvoiceService(t *testing.T) { config.Config.Mail = config.MailConfig{ ApiUrl: "", diff --git a/service/sumsubService.go b/service/sumsubService.go index 9fa2de4..f957495 100644 --- a/service/sumsubService.go +++ b/service/sumsubService.go @@ -71,19 +71,9 @@ func ProcessKycEvent(event model.SumsubEvent, kyc model.Kyc, userAddress string) } } else if event.ReviewResult.ReviewAnswer == "GREEN" { status = model.StatusApproved - userInfo, err := fetchUserInfo(&kyc) + err = createOrUpdateApprovedUserInfo(&kyc, userAddress) if err != nil { - return errors.New("error while getting user info: " + err.Error()) - } - err = SendKycConfirmedEmail(kyc.Email) - if err != nil { - return errors.New("error while sending email: " + err.Error()) - } - userInfo.BlockchainAddress = userAddress - userInfo.Email = kyc.Email - err = storage.CreateUserInfo(userInfo) - if err != nil { - return errors.New("error while creating userinfo: " + err.Error()) + return err } } kyc.KycStatus = status @@ -123,9 +113,9 @@ func ProcessKycEvent(event model.SumsubEvent, kyc model.Kyc, userAddress string) } } else if event.ReviewResult.ReviewAnswer == "GREEN" && event.Type == model.ApplicantOnHold { status = model.StatusApproved - err = SendKycConfirmedEmail(kyc.Email) + err = createOrUpdateApprovedUserInfo(&kyc, userAddress) if err != nil { - return errors.New("error while sending email: " + err.Error()) + return err } } kyc.KycStatus = status @@ -136,6 +126,29 @@ func ProcessKycEvent(event model.SumsubEvent, kyc model.Kyc, userAddress string) return errors.New("error while updateing kyc information on storage: " + err.Error()) } + if (event.Type == model.ApplicantReviewed || event.Type == model.ApplicantOnHold) && event.ReviewResult.ReviewAnswer == "GREEN" { + err = SendKycConfirmedEmail(kyc.Email) + if err != nil { + log.Warn("error while sending kyc confirmed email: " + err.Error()) + } + } + + return nil +} + +func createOrUpdateApprovedUserInfo(kyc *model.Kyc, userAddress string) error { + userInfo, err := fetchUserInfo(kyc) + if err != nil { + return errors.New("error while getting user info: " + err.Error()) + } + + userInfo.BlockchainAddress = userAddress + userInfo.Email = kyc.Email + err = storage.CreateOrUpdateUserInfo(userInfo) + if err != nil { + return errors.New("error while creating or updating userinfo: " + err.Error()) + } + return nil } diff --git a/storage/userInfoStorer.go b/storage/userInfoStorer.go index 74568b3..e8a5f0a 100644 --- a/storage/userInfoStorer.go +++ b/storage/userInfoStorer.go @@ -3,6 +3,7 @@ package storage import ( "github.com/NaeuralEdgeProtocol/ratio1-backend/model" "gorm.io/gorm" + "gorm.io/gorm/clause" ) func CreateUserInfo(userInfo *model.UserInfo) error { @@ -43,6 +44,39 @@ func UpdateUserInfo(userInfo *model.UserInfo) error { return nil } +func CreateOrUpdateUserInfo(userInfo *model.UserInfo) error { + db, err := GetDB() + if err != nil { + return err + } + + txUpdate := db.Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "blockchain_address"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "email", + "name", + "surname", + "company_name", + "identification_code", + "address", + "state", + "city", + "country", + "is_company", + }), + }).Create(userInfo) + if txUpdate.Error != nil { + txUpdate.Rollback() + return txUpdate.Error + } + if txUpdate.RowsAffected == 0 { + txUpdate.Rollback() + return gorm.ErrRecordNotFound + } + + return nil +} + func GetUserInfoByAddress(address string) (*model.UserInfo, error) { db, err := GetDB() if err != nil { diff --git a/templates/html/invoice.draft.html b/templates/html/invoice.draft.html index a850834..6ad9543 100644 --- a/templates/html/invoice.draft.html +++ b/templates/html/invoice.draft.html @@ -307,18 +307,16 @@

Allocations Details

- - - - - + + + + - - + @@ -328,8 +326,7 @@

Allocations Details

- - + @@ -339,4 +336,4 @@

Allocations Details

- \ No newline at end of file +
Date TypeProjectJob ID - NameJob ID Node USDC Paid
{{ .AllocationCreation }} {{ .JobType }}{{ .ProjectName }}{{ .JobID }} - {{ .JobName }}{{ .JobID }} {{ .NodeAddress }} {{ .UsdcPaid }}