Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 29 additions & 8 deletions service/emailService.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package service

import (
"bytes"
"encoding/base64"
"errors"
"fmt"
"time"
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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())
Expand All @@ -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 {
Expand Down Expand Up @@ -350,13 +358,18 @@ 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,
Subject: subject,
TextBody: "",
HtmlBody: htmlBody,
MessageStream: messageStreamSend,
Attachments: attachments,
}

var resp EmailSendResponse
Expand All @@ -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)
}
118 changes: 110 additions & 8 deletions service/monthlyPoaiInvoiceService.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"math/big"
"strings"
"time"
"unicode"

"github.com/NaeuralEdgeProtocol/ratio1-backend/model"
"github.com/NaeuralEdgeProtocol/ratio1-backend/storage"
Expand Down Expand Up @@ -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 {
Expand Down
67 changes: 67 additions & 0 deletions service/monthlyPoaiInvoiceService_test.go
Original file line number Diff line number Diff line change
@@ -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: "",
Expand Down
41 changes: 27 additions & 14 deletions service/sumsubService.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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())
}
}
Comment on lines +129 to +134

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
}

Expand Down
Loading
Loading