diff --git a/internal/api/api_client.go b/internal/api/api_client.go index fbefcdd..bb5b3af 100644 --- a/internal/api/api_client.go +++ b/internal/api/api_client.go @@ -3,6 +3,7 @@ package api import ( "context" "fmt" + "io" "net/http" ) @@ -10,7 +11,6 @@ type Context struct { Context context.Context Cookies []*http.Cookie XSRFToken string - ClientId int } const ErrUnauthorized ClientError = "unauthorized" @@ -21,18 +21,22 @@ func (e ClientError) Error() string { return string(e) } -func NewApiClient(httpClient HTTPClient) *ApiClient { - return &ApiClient{ - http: httpClient, - } +func NewClient(httpClient HTTPClient, siriusUrl string, backendUrl string) (*Client, error) { + return &Client{ + http: httpClient, + siriusUrl: siriusUrl, + backendUrl: backendUrl, + }, nil } type HTTPClient interface { Do(req *http.Request) (*http.Response, error) } -type ApiClient struct { - http HTTPClient +type Client struct { + http HTTPClient + siriusUrl string + backendUrl string } type StatusError struct { @@ -48,3 +52,27 @@ func (e StatusError) Error() string { func (e StatusError) Data() interface{} { return e } + +func (c *Client) newBackendRequest(ctx Context, method, path string, body io.Reader) (*http.Request, error) { + req, err := http.NewRequestWithContext(ctx.Context, method, c.backendUrl+path, body) + if err != nil { + return nil, err + } + + for _, c := range ctx.Cookies { + req.AddCookie(c) + } + + req.Header.Add("OPG-Bypass-Membrane", "1") + req.Header.Add("X-XSRF-TOKEN", ctx.XSRFToken) + + return req, err +} + +func newStatusError(resp *http.Response) StatusError { + return StatusError{ + Code: resp.StatusCode, + URL: resp.Request.URL.String(), + Method: resp.Request.Method, + } +} diff --git a/internal/api/api_client_test.go b/internal/api/api_client_test.go new file mode 100644 index 0000000..16d58f7 --- /dev/null +++ b/internal/api/api_client_test.go @@ -0,0 +1,46 @@ +package api + +import ( + "context" + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +type MockClient struct { +} + +var ( + GetDoFunc func(req *http.Request) (*http.Response, error) +) + +func (m *MockClient) Do(req *http.Request) (*http.Response, error) { + return GetDoFunc(req) +} + +func getContext(cookies []*http.Cookie) Context { + return Context{ + Context: context.Background(), + Cookies: cookies, + XSRFToken: "abcde", + } +} + +func TestClientError(t *testing.T) { + assert.Equal(t, "message", ClientError("message").Error()) +} + +func TestStatusError(t *testing.T) { + req, _ := http.NewRequest(http.MethodPost, "/some/url", nil) + + resp := &http.Response{ + StatusCode: http.StatusTeapot, + Request: req, + } + + err := newStatusError(resp) + + assert.Equal(t, "POST /some/url returned 418", err.Error()) + assert.Equal(t, err, err.Data()) +} diff --git a/internal/api/download.go b/internal/api/download.go new file mode 100644 index 0000000..b7e514e --- /dev/null +++ b/internal/api/download.go @@ -0,0 +1,59 @@ +package api + +import ( + "bytes" + "encoding/json" + "github.com/opg-sirius-finance-admin/internal/model" + "net/http" +) + +func (c *Client) Download(ctx Context, data model.Download) error { + var body bytes.Buffer + + err := json.NewEncoder(&body).Encode(data) + if err != nil { + return err + } + + req, err := c.newBackendRequest(ctx, http.MethodGet, "/downloads", &body) + + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case http.StatusCreated: + return nil + + case http.StatusUnauthorized: + return ErrUnauthorized + + case http.StatusUnprocessableEntity: + var v model.ValidationError + if err := json.NewDecoder(resp.Body).Decode(&v); err == nil && len(v.Errors) > 0 { + return model.ValidationError{Errors: v.Errors} + } + + case http.StatusBadRequest: + var badRequests model.BadRequests + if err := json.NewDecoder(resp.Body).Decode(&badRequests); err != nil { + return err + } + + validationErrors := model.ValidationErrors{} + for _, reason := range badRequests.Reasons { + validationErrors[reason] = map[string]string{reason: reason} + } + + return model.ValidationError{Errors: validationErrors} + } + + return newStatusError(resp) +} diff --git a/internal/api/download_test.go b/internal/api/download_test.go new file mode 100644 index 0000000..6746dc7 --- /dev/null +++ b/internal/api/download_test.go @@ -0,0 +1,138 @@ +package api + +import ( + "bytes" + "encoding/json" + "github.com/opg-sirius-finance-admin/internal/model" + "github.com/stretchr/testify/assert" + "io" + "net/http" + "net/http/httptest" + "testing" +) + +func TestSubmitDownload(t *testing.T) { + mockClient := &MockClient{} + client, _ := NewClient(mockClient, "http://localhost:3000", "") + dateOfTransaction := model.NewDate("2024-05-11") + dateTo := model.NewDate("2025-06-15") + dateFrom := model.NewDate("2022-07-21") + + data := model.Download{ + ReportType: "reportType", + ReportJournalType: "reportJournalType", + ReportScheduleType: "reportScheduleType", + ReportAccountType: "reportAccountType", + ReportDebtType: "reportDebtType", + DateOfTransaction: &dateOfTransaction, + ToDateField: &dateTo, + FromDateField: &dateFrom, + Email: "Something@example.com", + } + + GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusCreated, + Body: io.NopCloser(bytes.NewReader([]byte{})), + }, nil + } + + err := client.Download(getContext(nil), data) + assert.NoError(t, err) +} + +func TestSubmitDownloadUnauthorised(t *testing.T) { + mockClient := &MockClient{} + client, _ := NewClient(mockClient, "http://localhost:3000", "") + + data := model.Download{ + ReportType: "reportType", + ReportJournalType: "reportJournalType", + ReportScheduleType: "reportScheduleType", + ReportAccountType: "reportAccountType", + ReportDebtType: "reportDebtType", + DateOfTransaction: nil, + ToDateField: nil, + FromDateField: nil, + Email: "Something@example.com", + } + + GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusUnauthorized, + Body: io.NopCloser(bytes.NewReader([]byte{})), + }, nil + } + + err := client.Download(getContext(nil), data) + + assert.Equal(t, ErrUnauthorized.Error(), err.Error()) +} + +func TestSubmitDownloadReturnsBadRequestError(t *testing.T) { + mockClient := &MockClient{} + client, _ := NewClient(mockClient, "http://localhost:3000", "") + + data := model.Download{ + ReportType: "reportType", + ReportJournalType: "reportJournalType", + ReportScheduleType: "reportScheduleType", + ReportAccountType: "reportAccountType", + ReportDebtType: "reportDebtType", + DateOfTransaction: nil, + ToDateField: nil, + FromDateField: nil, + Email: "Something@example.com", + } + + json := `{"reasons":["StartDate","EndDate"]}` + + r := io.NopCloser(bytes.NewReader([]byte(json))) + + GetDoFunc = func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadRequest, + Body: r, + }, nil + } + + err := client.Download(getContext(nil), data) + + expectedError := model.ValidationError{Message: "", Errors: model.ValidationErrors{"EndDate": map[string]string{"EndDate": "EndDate"}, "StartDate": map[string]string{"StartDate": "StartDate"}}} + assert.Equal(t, expectedError, err) +} + +func TestSubmitDownloadReturnsValidationError(t *testing.T) { + data := model.Download{ + ReportType: "", + ReportJournalType: "reportJournalType", + ReportScheduleType: "reportScheduleType", + ReportAccountType: "reportAccountType", + ReportDebtType: "reportDebtType", + DateOfTransaction: nil, + ToDateField: nil, + FromDateField: nil, + Email: "Something@example.com", + } + + validationErrors := model.ValidationError{ + Message: "Validation failed", + Errors: map[string]map[string]string{ + "ReportType": { + "required": "Please select a report type", + }, + }, + } + responseBody, _ := json.Marshal(validationErrors) + svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write(responseBody) + })) + defer svr.Close() + + client, _ := NewClient(http.DefaultClient, svr.URL, svr.URL) + + err := client.Download(getContext(nil), data) + expectedError := model.ValidationError{Message: "", Errors: model.ValidationErrors{"ReportType": map[string]string{"required": "Please select a report type"}}} + assert.Equal(t, expectedError, err.(model.ValidationError)) +} diff --git a/internal/model/account_type.go b/internal/model/account_type.go new file mode 100644 index 0000000..6d4bc8a --- /dev/null +++ b/internal/model/account_type.go @@ -0,0 +1,114 @@ +package model + +import "encoding/json" + +var ReportAccountTypes = []ReportAccountType{ + ReportAccountTypeAgedDebt, + ReportAccountTypeUnappliedReceipts, + ReportAccountTypeCustomerAgeingBuckets, + ReportAccountTypeARPaidInvoiceReport, + ReportAccountTypePaidInvoiceTransactionLines, + ReportAccountTypeTotalReceiptsReport, + ReportAccountTypeBadDebtWriteOffReport, + ReportAccountTypeFeeAccrual, +} + +var reportAccountTypeMap = map[string]ReportAccountType{ + "AgedDebt": ReportAccountTypeAgedDebt, + "UnappliedReceipts": ReportAccountTypeUnappliedReceipts, + "CustomerAgeingBuckets": ReportAccountTypeCustomerAgeingBuckets, + "ARPaidInvoiceReport": ReportAccountTypeARPaidInvoiceReport, + "PaidInvoiceTransactionLines": ReportAccountTypePaidInvoiceTransactionLines, + "TotalReceiptsReport": ReportAccountTypeTotalReceiptsReport, + "BadDebtWriteOffReport": ReportAccountTypeBadDebtWriteOffReport, + "FeeAccrual": ReportAccountTypeFeeAccrual, +} + +type ReportAccountType int + +const ( + ReportAccountTypeUnknown ReportAccountType = iota + ReportAccountTypeAgedDebt + ReportAccountTypeUnappliedReceipts + ReportAccountTypeCustomerAgeingBuckets + ReportAccountTypeARPaidInvoiceReport + ReportAccountTypePaidInvoiceTransactionLines + ReportAccountTypeTotalReceiptsReport + ReportAccountTypeBadDebtWriteOffReport + ReportAccountTypeFeeAccrual +) + +func (i ReportAccountType) String() string { + return i.Key() +} + +func (i ReportAccountType) Translation() string { + switch i { + case ReportAccountTypeAgedDebt: + return "Aged Debt" + case ReportAccountTypeUnappliedReceipts: + return "Unapplied Receipts" + case ReportAccountTypeCustomerAgeingBuckets: + return "Customer Ageing Buckets" + case ReportAccountTypeARPaidInvoiceReport: + return "AR Paid Invoice Report" + case ReportAccountTypePaidInvoiceTransactionLines: + return "Paid Invoice Transaction Lines" + case ReportAccountTypeTotalReceiptsReport: + return "Total Receipts Report" + case ReportAccountTypeBadDebtWriteOffReport: + return "Bad Debt Write-off Report" + case ReportAccountTypeFeeAccrual: + return "Fee Accrual" + default: + return "" + } +} + +func (i ReportAccountType) Key() string { + switch i { + case ReportAccountTypeAgedDebt: + return "AgedDebt" + case ReportAccountTypeUnappliedReceipts: + return "UnappliedReceipts" + case ReportAccountTypeCustomerAgeingBuckets: + return "CustomerAgeingBuckets" + case ReportAccountTypeARPaidInvoiceReport: + return "ARPaidInvoiceReport" + case ReportAccountTypePaidInvoiceTransactionLines: + return "PaidInvoiceTransactionLines" + case ReportAccountTypeTotalReceiptsReport: + return "TotalReceiptsReport" + case ReportAccountTypeBadDebtWriteOffReport: + return "BadDebtWriteOffReport" + case ReportAccountTypeFeeAccrual: + return "FeeAccrual" + default: + return "" + } +} + +func ParseReportAccountType(s string) ReportAccountType { + value, ok := reportAccountTypeMap[s] + if !ok { + return ReportAccountType(0) + } + return value +} + +func (i ReportAccountType) Valid() bool { + return i != ReportAccountTypeUnknown +} + +func (i ReportAccountType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.Key()) +} + +func (i *ReportAccountType) UnmarshalJSON(data []byte) (err error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *i = ParseReportAccountType(s) + return nil +} diff --git a/internal/model/date.go b/internal/model/date.go new file mode 100644 index 0000000..d1e9033 --- /dev/null +++ b/internal/model/date.go @@ -0,0 +1,82 @@ +package model + +import ( + "strings" + "time" +) + +type Date struct { + Time time.Time +} + +func NewDate(d string) Date { + t, err := stringToTime(d) + if err != nil { + panic(err) + } + + return Date{Time: t} +} + +func (d Date) Before(d2 Date) bool { + return d.Time.Before(d2.Time) +} + +func (d Date) After(d2 Date) bool { + return d.Time.After(d2.Time) +} + +func (d Date) String() string { + if d.IsNull() { + return "" + } + return d.Time.Format("02/01/2006") +} + +func (d Date) IsNull() bool { + nullDate := NewDate("01/01/0001") + return d.Time.Equal(nullDate.Time) +} + +func (d *Date) UnmarshalJSON(b []byte) error { + t, err := stringToTime(string(b)) + if err != nil { + return err + } + + *d = Date{Time: t} + return nil +} + +func stringToTime(s string) (time.Time, error) { + value := strings.Trim(s, `"`) + if value == "" || value == "null" { + return time.Time{}, nil + } + + value = strings.ReplaceAll(value, `\`, "") + supportedFormats := []string{ + "02/01/2006", + "2006-01-02T15:04:05+00:00", + "2006-01-02", + } + + var t time.Time + var err error + + for _, format := range supportedFormats { + t, err = time.Parse(format, value) + if err != nil { + continue + } + break + } + if err != nil { + return time.Time{}, err + } + return t, nil +} + +func (d Date) MarshalJSON() ([]byte, error) { + return []byte(`"` + d.Time.Format("02\\/01\\/2006") + `"`), nil +} diff --git a/internal/model/date_test.go b/internal/model/date_test.go new file mode 100644 index 0000000..d504cad --- /dev/null +++ b/internal/model/date_test.go @@ -0,0 +1,162 @@ +package model + +import ( + "encoding/json" + "github.com/stretchr/testify/assert" + "strconv" + "testing" +) + +type testJsonDateStruct struct { + TestDate Date `json:"testDate"` +} + +func TestDate_Before_And_After(t *testing.T) { + tests := []struct { + name string + date1 Date + date2 Date + wantForBeforeTest bool + wantForAfterTest bool + }{ + { + name: "Date1 is before Date2", + date1: NewDate("01/01/2020"), + date2: NewDate("02/01/2020"), + wantForBeforeTest: true, + wantForAfterTest: false, + }, + { + name: "Date1 is after Date2", + date1: NewDate("02/01/2020"), + date2: NewDate("01/01/2020"), + wantForBeforeTest: false, + wantForAfterTest: true, + }, + { + name: "Date1 is the same as Date2", + date1: NewDate("01/01/2020"), + date2: NewDate("01/01/2020"), + wantForBeforeTest: false, + wantForAfterTest: false, + }, + { + name: "Date1 is empty", + date1: Date{}, + date2: NewDate("02/01/2020"), + wantForBeforeTest: true, + wantForAfterTest: false, + }, + { + name: "Date2 is empty", + date1: NewDate("01/01/2020"), + date2: Date{}, + wantForBeforeTest: false, + wantForAfterTest: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.wantForBeforeTest, test.date1.Before(test.date2)) + assert.Equal(t, test.wantForAfterTest, test.date1.After(test.date2)) + }) + } +} + +func TestDate_IsNull(t *testing.T) { + tests := []struct { + name string + date Date + want bool + }{ + { + name: "Date passed in is not null", + date: NewDate("01/01/2020"), + want: false, + }, + { + name: "Date passed matches a nil date", + date: NewDate("01/01/0001"), + want: true, + }, + { + name: "Date passed is null", + date: Date{}, + want: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert.Equal(t, test.want, test.date.IsNull()) + }) + } +} + +func TestDate_MarshalJSON(t *testing.T) { + v := testJsonDateStruct{TestDate: NewDate("01/01/2020")} + b, err := json.Marshal(v) + assert.Nil(t, err) + assert.Equal(t, `{"testDate":"01\/01\/2020"}`, string(b)) +} + +func TestDate_String(t *testing.T) { + tests := []struct { + name string + inputDate string + want string + }{ + { + name: "returns correct format for slashers", + inputDate: "01/01/2020", + want: "01/01/2020", + }, + { + name: "returns correct format for dashers", + inputDate: "2024-10-01", + want: "01/10/2024", + }, + { + name: "returns correct format for date time string", + inputDate: "2025-01-02T18:07:10+00:00", + want: "02/01/2025", + }, + { + name: "returns nothing for default date string", + inputDate: "01/01/0001", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, NewDate(tt.inputDate).String(), "stringToTime(%v)", tt.inputDate) + }) + } +} + +func TestDate_UnmarshalJSON(t *testing.T) { + tests := []struct { + json string + want string + }{ + { + json: `{"testDate":"01\/01\/2020"}`, + want: "01/01/2020", + }, + { + json: `{"testDate":"01/01/2020"}`, + want: "01/01/2020", + }, + { + json: `{"testDate":"2020-01-01T20:01:02+00:00"}`, + want: "01/01/2020", + }, + } + for i, test := range tests { + t.Run("Scenario "+strconv.Itoa(i+1), func(t *testing.T) { + var v *testJsonDateStruct + err := json.Unmarshal([]byte(test.json), &v) + assert.Nil(t, err) + assert.Equal(t, test.want, v.TestDate.String()) + }) + } +} diff --git a/internal/model/debt_type.go b/internal/model/debt_type.go new file mode 100644 index 0000000..c053245 --- /dev/null +++ b/internal/model/debt_type.go @@ -0,0 +1,72 @@ +package model + +import "encoding/json" + +var ReportDebtTypes = []ReportDebtType{ + ReportDebtTypeFeeChase, + ReportDebtTypeFinalFee, +} + +type ReportDebtType int + +const ( + ReportDebtTypeUnknown ReportDebtType = iota + ReportDebtTypeFeeChase + ReportDebtTypeFinalFee +) + +var reportDebtTypeMap = map[string]ReportDebtType{ + "FeeChase": ReportDebtTypeFeeChase, + "FinalFee": ReportDebtTypeFinalFee, +} + +func (i ReportDebtType) String() string { + return i.Key() +} + +func (i ReportDebtType) Translation() string { + switch i { + case ReportDebtTypeFeeChase: + return "Fee Chase" + case ReportDebtTypeFinalFee: + return "Final Fee" + default: + return "" + } +} + +func (i ReportDebtType) Key() string { + switch i { + case ReportDebtTypeFeeChase: + return "FeeChase" + case ReportDebtTypeFinalFee: + return "FinalFee" + default: + return "" + } +} + +func ParseReportDebtType(s string) ReportDebtType { + value, ok := reportDebtTypeMap[s] + if !ok { + return ReportDebtType(0) + } + return value +} + +func (i ReportDebtType) Valid() bool { + return i != ReportDebtTypeUnknown +} + +func (i ReportDebtType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.Key()) +} + +func (i *ReportDebtType) UnmarshalJSON(data []byte) (err error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *i = ParseReportDebtType(s) + return nil +} diff --git a/internal/model/download.go b/internal/model/download.go new file mode 100644 index 0000000..587685c --- /dev/null +++ b/internal/model/download.go @@ -0,0 +1,41 @@ +package model + +type Download struct { + ReportType string `json:"reportType"` + ReportJournalType string `json:"reportJournalType"` + ReportScheduleType string `json:"reportScheduleType"` + ReportAccountType string `json:"reportAccountType"` + ReportDebtType string `json:"reportDebtType"` + DateOfTransaction *Date `json:"dateOfTransaction,omitempty"` + ToDateField *Date `json:"toDateField,omitempty"` + FromDateField *Date `json:"fromDateField,omitempty"` + Email string `json:"email"` +} + +func NewDownload(reportType, reportJournalType, reportScheduleType, reportAccountType, reportDebtType, dateOfTransaction, dateTo, dateFrom, email string) Download { + download := Download{ + ReportType: reportType, + ReportJournalType: reportJournalType, + ReportScheduleType: reportScheduleType, + ReportAccountType: reportAccountType, + ReportDebtType: reportDebtType, + Email: email, + } + + if dateOfTransaction != "" { + raisedDateFormatted := NewDate(dateOfTransaction) + download.DateOfTransaction = &raisedDateFormatted + } + + if dateTo != "" { + startDateFormatted := NewDate(dateTo) + download.ToDateField = &startDateFormatted + } + + if dateFrom != "" { + endDateFormatted := NewDate(dateFrom) + download.FromDateField = &endDateFormatted + } + + return download +} diff --git a/internal/model/download_test.go b/internal/model/download_test.go new file mode 100644 index 0000000..baf2e21 --- /dev/null +++ b/internal/model/download_test.go @@ -0,0 +1,99 @@ +package model + +import ( + "github.com/stretchr/testify/assert" + "testing" + "time" +) + +func TestNewDownload(t *testing.T) { + type args struct { + reportType string + reportJournalType string + reportScheduleType string + reportAccountType string + reportDebtType string + dateOfTransaction string + dateTo string + dateFrom string + email string + } + + dateOfTransaction, _ := time.Parse("02/01/2006", "11/05/2024") + dateTo, _ := time.Parse("02/01/2006", "15/06/2025") + dateFrom, _ := time.Parse("02/01/2006", "21/07/2022") + + tests := []struct { + name string + args args + want Download + }{ + { + name: "Returns all fields", + args: args{ + reportType: "reportType", + reportJournalType: "reportJournalType", + reportScheduleType: "reportScheduleType", + reportAccountType: "reportAccountType", + reportDebtType: "reportDebtType", + dateOfTransaction: "11/05/2024", + dateTo: "15/06/2025", + dateFrom: "21/07/2022", + email: "Something@example.com", + }, + want: Download{ + ReportType: "reportType", + ReportJournalType: "reportJournalType", + ReportScheduleType: "reportScheduleType", + ReportAccountType: "reportAccountType", + ReportDebtType: "reportDebtType", + DateOfTransaction: &Date{dateOfTransaction}, + ToDateField: &Date{dateTo}, + FromDateField: &Date{dateFrom}, + Email: "Something@example.com", + }, + }, + { + name: "Returns with missing optional fields", + args: args{ + reportType: "reportType", + reportJournalType: "reportJournalType", + reportScheduleType: "reportScheduleType", + reportAccountType: "reportAccountType", + reportDebtType: "reportDebtType", + dateOfTransaction: "", + dateTo: "", + dateFrom: "", + email: "Something@example.com", + }, + want: Download{ + ReportType: "reportType", + ReportJournalType: "reportJournalType", + ReportScheduleType: "reportScheduleType", + ReportAccountType: "reportAccountType", + ReportDebtType: "reportDebtType", + DateOfTransaction: nil, + ToDateField: nil, + FromDateField: nil, + Email: "Something@example.com", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := NewDownload( + tt.args.reportType, + tt.args.reportJournalType, + tt.args.reportScheduleType, + tt.args.reportAccountType, + tt.args.reportDebtType, + tt.args.dateOfTransaction, + tt.args.dateTo, + tt.args.dateFrom, + tt.args.email, + ) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/model/error.go b/internal/model/error.go new file mode 100644 index 0000000..1776cd7 --- /dev/null +++ b/internal/model/error.go @@ -0,0 +1,23 @@ +package model + +import ( + "fmt" + "strings" +) + +type BadRequest struct { + Field string `json:"field"` + Reason string `json:"reason"` +} + +func (b BadRequest) Error() string { + return b.Reason +} + +type BadRequests struct { + Reasons []string `json:"reasons"` +} + +func (b BadRequests) Error() string { + return fmt.Sprintf("bad requests: %s", strings.Join(b.Reasons, ", ")) +} diff --git a/internal/model/journal_type.go b/internal/model/journal_type.go new file mode 100644 index 0000000..3bc976c --- /dev/null +++ b/internal/model/journal_type.go @@ -0,0 +1,72 @@ +package model + +import "encoding/json" + +var ReportJournalTypes = []ReportJournalType{ + ReportTypeReceiptTransactions, + ReportTypeNonReceiptTransactions, +} + +const ( + ReportTypeUnknown ReportJournalType = iota + ReportTypeReceiptTransactions + ReportTypeNonReceiptTransactions +) + +var reportJournalTypeMap = map[string]ReportJournalType{ + "ReceiptTransactions": ReportTypeReceiptTransactions, + "NonReceiptTransactions": ReportTypeNonReceiptTransactions, +} + +type ReportJournalType int + +func (i ReportJournalType) String() string { + return i.Key() +} + +func (i ReportJournalType) Translation() string { + switch i { + case ReportTypeReceiptTransactions: + return "Receipt Transactions" + case ReportTypeNonReceiptTransactions: + return "Non Receipt Transactions" + default: + return "" + } +} + +func (i ReportJournalType) Key() string { + switch i { + case ReportTypeReceiptTransactions: + return "ReceiptTransactions" + case ReportTypeNonReceiptTransactions: + return "NonReceiptTransactions" + default: + return "" + } +} + +func ParseReportJournalType(s string) ReportJournalType { + value, ok := reportJournalTypeMap[s] + if !ok { + return ReportJournalType(0) + } + return value +} + +func (i ReportJournalType) Valid() bool { + return i != ReportTypeUnknown +} + +func (i ReportJournalType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.Key()) +} + +func (i *ReportJournalType) UnmarshalJSON(data []byte) (err error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *i = ParseReportJournalType(s) + return nil +} diff --git a/internal/model/reports_type.go b/internal/model/reports_type.go new file mode 100644 index 0000000..119f73d --- /dev/null +++ b/internal/model/reports_type.go @@ -0,0 +1,94 @@ +package model + +import "encoding/json" + +var ReportsTypes = []ReportsType{ + ReportsTypeJournal, + ReportsTypeSchedule, + ReportsTypeAccountsReceivable, + ReportsTypeDebt, +} + +type ReportsType int + +const ( + ReportsTypeUnknown ReportsType = iota + ReportsTypeJournal + ReportsTypeSchedule + ReportsTypeAccountsReceivable + ReportsTypeDebt +) + +var reportsTypeMap = map[string]ReportsType{ + "Journal": ReportsTypeJournal, + "Schedule": ReportsTypeSchedule, + "AccountsReceivable": ReportsTypeAccountsReceivable, + "Debt": ReportsTypeDebt, +} + +func (i ReportsType) String() string { + return i.Key() +} + +func (i ReportsType) Translation() string { + switch i { + case ReportsTypeJournal: + return "Journal" + case ReportsTypeSchedule: + return "Schedule" + case ReportsTypeAccountsReceivable: + return "Accounts Receivable" + case ReportsTypeDebt: + return "Debt" + default: + return "" + } +} + +func (i ReportsType) Key() string { + switch i { + case ReportsTypeJournal: + return "Journal" + case ReportsTypeSchedule: + return "Schedule" + case ReportsTypeAccountsReceivable: + return "AccountsReceivable" + case ReportsTypeDebt: + return "Debt" + default: + return "" + } +} + +func ParseReportsType(s string) ReportsType { + value, ok := reportsTypeMap[s] + if !ok { + return ReportsType(0) + } + return value +} + +func (i ReportsType) Valid() bool { + return i != ReportsTypeUnknown +} + +func (i ReportsType) RequiresDateValidation() bool { + switch i { + case ReportsTypeJournal: + return true + } + return false +} + +func (i ReportsType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.Key()) +} + +func (i *ReportsType) UnmarshalJSON(data []byte) (err error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *i = ParseReportsType(s) + return nil +} diff --git a/internal/model/schedule_type.go b/internal/model/schedule_type.go new file mode 100644 index 0000000..9d8fae2 --- /dev/null +++ b/internal/model/schedule_type.go @@ -0,0 +1,247 @@ +package model + +import "encoding/json" + +var ReportScheduleTypes = []ReportScheduleType{ + ReportTypeMOTOCardPayments, + ReportTypeOnlineCardPayments, + ReportOPGBACSTransfer, + ReportSupervisionBACSTransfer, + ReportDirectDebitPayments, + ReportAdFeeInvoices, + ReportS2FeeInvoices, + ReportS3FeeInvoices, + ReportB2FeeInvoices, + ReportB3FeeInvoices, + ReportSFFeeInvoicesGeneral, + ReportSFFeeInvoicesMinimal, + ReportSEFeeInvoicesGeneral, + ReportSEFeeInvoicesMinimal, + ReportSOFeeInvoicesGeneral, + ReportSOFeeInvoicesMinimal, + ReportADFeeReductions, + ReportGeneralManualCredits, + ReportMinimalManualCredits, + ReportGeneralManualDebits, + ReportMinimalManualDebits, + ReportADWriteOffs, + ReportGeneralWriteOffs, + ReportMinimalWriteOffs, + ReportADWriteOffReversals, + ReportGeneralWriteOffReversals, + ReportMinimalWriteOffReversals, +} + +type ReportScheduleType int + +const ( + ReportScheduleTypeUnknown ReportScheduleType = iota + ReportTypeMOTOCardPayments + ReportTypeOnlineCardPayments + ReportOPGBACSTransfer + ReportSupervisionBACSTransfer + ReportDirectDebitPayments + ReportAdFeeInvoices + ReportS2FeeInvoices + ReportS3FeeInvoices + ReportB2FeeInvoices + ReportB3FeeInvoices + ReportSFFeeInvoicesGeneral + ReportSFFeeInvoicesMinimal + ReportSEFeeInvoicesGeneral + ReportSEFeeInvoicesMinimal + ReportSOFeeInvoicesGeneral + ReportSOFeeInvoicesMinimal + ReportADFeeReductions + ReportGeneralManualCredits + ReportMinimalManualCredits + ReportGeneralManualDebits + ReportMinimalManualDebits + ReportADWriteOffs + ReportGeneralWriteOffs + ReportMinimalWriteOffs + ReportADWriteOffReversals + ReportGeneralWriteOffReversals + ReportMinimalWriteOffReversals +) + +var reportScheduleTypeMap = map[string]ReportScheduleType{ + "Accounts Receivable": ReportTypeMOTOCardPayments, + "OnlineCardPayments": ReportTypeOnlineCardPayments, + "OPGBACSTransfer": ReportOPGBACSTransfer, + "SupervisionBACSTransfer": ReportSupervisionBACSTransfer, + "DirectDebitPayment": ReportDirectDebitPayments, + "AdFeeInvoices": ReportAdFeeInvoices, + "S2FeeInvoices": ReportS2FeeInvoices, + "S3FeeInvoices": ReportS3FeeInvoices, + "B2FeeInvoices": ReportB2FeeInvoices, + "B3FeeInvoices": ReportB3FeeInvoices, + "SFFeeInvoicesGeneral ": ReportSFFeeInvoicesGeneral, + "SFFeeInvoicesMinimal": ReportSFFeeInvoicesMinimal, + "SEFeeInvoicesGeneral": ReportSEFeeInvoicesGeneral, + "SEFeeInvoicesMinimal": ReportSEFeeInvoicesMinimal, + "SOFeeInvoicesGeneral": ReportSOFeeInvoicesGeneral, + "SOFeeInvoicesMinimal": ReportSOFeeInvoicesMinimal, + "ADFeeReductions": ReportADFeeReductions, + "GeneralManualCredits": ReportGeneralManualCredits, + "MinimalManualCredits": ReportMinimalManualCredits, + "GeneralManualDebits": ReportGeneralManualDebits, + "MinimalManualDebits": ReportMinimalManualDebits, + "ADWrite-offs": ReportADWriteOffs, + "GeneralWrite-offs": ReportGeneralWriteOffs, + "MinimalWriteOffs": ReportMinimalWriteOffs, + "ADWriteOffReversals": ReportADWriteOffReversals, + "GeneralWriteOffReversals": ReportGeneralWriteOffReversals, + "MinimalWriteOffReversals": ReportMinimalWriteOffReversals, +} + +func (i ReportScheduleType) String() string { + return i.Key() +} + +func (i ReportScheduleType) Translation() string { + switch i { + case ReportTypeMOTOCardPayments: + return "Accounts Receivable" + case ReportTypeOnlineCardPayments: + return "Online Card Payments" + case ReportOPGBACSTransfer: + return "OPG BACS Transfer" + case ReportSupervisionBACSTransfer: + return "Supervision BACS transfer" + case ReportDirectDebitPayments: + return "Direct Debit Payment" + case ReportAdFeeInvoices: + return "Ad Fee Invoices" + case ReportS2FeeInvoices: + return "S2 Fee Invoices" + case ReportS3FeeInvoices: + return "S3 Fee Invoices" + case ReportB2FeeInvoices: + return "B2 Fee Invoices" + case ReportB3FeeInvoices: + return "B3 Fee Invoices" + case ReportSFFeeInvoicesGeneral: + return "SF Fee Invoices (General) " + case ReportSFFeeInvoicesMinimal: + return "SF Fee Invoices (Minimal)" + case ReportSEFeeInvoicesGeneral: + return "SE Fee Invoices (General)" + case ReportSEFeeInvoicesMinimal: + return "SE Fee Invoices (Minimal)" + case ReportSOFeeInvoicesGeneral: + return "SO Fee Invoices (General)" + case ReportSOFeeInvoicesMinimal: + return "SO Fee Invoices (Minimal)" + case ReportADFeeReductions: + return "AD Fee Reductions" + case ReportGeneralManualCredits: + return "General Manual Credits" + case ReportMinimalManualCredits: + return "Minimal Manual Credits" + case ReportGeneralManualDebits: + return "General Manual Debits" + case ReportMinimalManualDebits: + return "Minimal Manual Debits" + case ReportADWriteOffs: + return "AD Write-offs" + case ReportGeneralWriteOffs: + return "General Write-offs" + case ReportMinimalWriteOffs: + return "Minimal Write-offs" + case ReportADWriteOffReversals: + return "AD Write-off Reversals" + case ReportGeneralWriteOffReversals: + return "General Write-off Reversals" + case ReportMinimalWriteOffReversals: + return "Minimal Write-off Reversals" + default: + return "" + } +} + +func (i ReportScheduleType) Key() string { + switch i { + case ReportTypeMOTOCardPayments: + return "Accounts Receivable" + case ReportTypeOnlineCardPayments: + return "OnlineCardPayments" + case ReportOPGBACSTransfer: + return "OPGBACSTransfer" + case ReportSupervisionBACSTransfer: + return "SupervisionBACSTransfer" + case ReportDirectDebitPayments: + return "DirectDebitPayment" + case ReportAdFeeInvoices: + return "AdFeeInvoices" + case ReportS2FeeInvoices: + return "S2FeeInvoices" + case ReportS3FeeInvoices: + return "S3FeeInvoices" + case ReportB2FeeInvoices: + return "B2FeeInvoices" + case ReportB3FeeInvoices: + return "B3FeeInvoices" + case ReportSFFeeInvoicesGeneral: + return "SFFeeInvoicesGeneral " + case ReportSFFeeInvoicesMinimal: + return "SFFeeInvoicesMinimal" + case ReportSEFeeInvoicesGeneral: + return "SEFeeInvoicesGeneral" + case ReportSEFeeInvoicesMinimal: + return "SEFeeInvoicesMinimal" + case ReportSOFeeInvoicesGeneral: + return "SOFeeInvoicesGeneral" + case ReportSOFeeInvoicesMinimal: + return "SOFeeInvoicesMinimal" + case ReportADFeeReductions: + return "ADFeeReductions" + case ReportGeneralManualCredits: + return "GeneralManualCredits" + case ReportMinimalManualCredits: + return "MinimalManualCredits" + case ReportGeneralManualDebits: + return "GeneralManualDebits" + case ReportMinimalManualDebits: + return "MinimalManualDebits" + case ReportADWriteOffs: + return "ADWrite-offs" + case ReportGeneralWriteOffs: + return "GeneralWrite-offs" + case ReportMinimalWriteOffs: + return "MinimalWriteOffs" + case ReportADWriteOffReversals: + return "ADWriteOffReversals" + case ReportGeneralWriteOffReversals: + return "GeneralWriteOffReversals" + case ReportMinimalWriteOffReversals: + return "MinimalWriteOffReversals" + default: + return "" + } +} + +func ParseReportScheduleType(s string) ReportScheduleType { + value, ok := reportScheduleTypeMap[s] + if !ok { + return ReportScheduleType(0) + } + return value +} + +func (i ReportScheduleType) Valid() bool { + return i != ReportScheduleTypeUnknown +} + +func (i ReportScheduleType) MarshalJSON() ([]byte, error) { + return json.Marshal(i.Key()) +} + +func (i *ReportScheduleType) UnmarshalJSON(data []byte) (err error) { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + *i = ParseReportScheduleType(s) + return nil +} diff --git a/internal/model/validation.go b/internal/model/validation.go new file mode 100644 index 0000000..18965f4 --- /dev/null +++ b/internal/model/validation.go @@ -0,0 +1,12 @@ +package model + +type ValidationErrors map[string]map[string]string + +type ValidationError struct { + Message string + Errors ValidationErrors `json:"validation_errors"` +} + +func (ve ValidationError) Error() string { + return ve.Message +} diff --git a/internal/server/app_vars.go b/internal/server/app_vars.go index 7792f03..a69a096 100644 --- a/internal/server/app_vars.go +++ b/internal/server/app_vars.go @@ -1,16 +1,18 @@ package server import ( + "github.com/opg-sirius-finance-admin/internal/model" "net/http" "net/url" ) type AppVars struct { - Path string - XSRFToken string - Tabs []Tab - EnvironmentVars EnvironmentVars - Error string + Path string + XSRFToken string + Tabs []Tab + EnvironmentVars EnvironmentVars + ValidationErrors model.ValidationErrors + Error string } type Tab struct { diff --git a/internal/server/app_vars_test.go b/internal/server/app_vars_test.go index e49bcb7..a6cc57b 100644 --- a/internal/server/app_vars_test.go +++ b/internal/server/app_vars_test.go @@ -8,7 +8,6 @@ import ( func TestNewAppVars(t *testing.T) { r, _ := http.NewRequest("GET", "/path", nil) - r.SetPathValue("clientId", "1") r.AddCookie(&http.Cookie{Name: "XSRF-TOKEN", Value: "abc123"}) envVars := EnvironmentVars{} diff --git a/internal/server/download.go b/internal/server/download.go new file mode 100644 index 0000000..e3af3f5 --- /dev/null +++ b/internal/server/download.go @@ -0,0 +1,50 @@ +package server + +import ( + "errors" + "github.com/opg-sirius-finance-admin/internal/api" + "github.com/opg-sirius-finance-admin/internal/model" + "net/http" +) + +type GetDownloadHandler struct { + router +} + +func (h *GetDownloadHandler) render(v AppVars, w http.ResponseWriter, r *http.Request) error { + ctx := getContext(r) + params := r.Form + + var ( + reportType = params.Get("reportType") + reportJournalType = params.Get("reportJournalType") + reportScheduleType = params.Get("reportScheduleType") + reportAccountType = params.Get("reportAccountType") + reportDebtType = params.Get("reportDebtType") + dateOfTransaction = params.Get("dateOfTransaction") + dateFrom = params.Get("dateFrom") + dateTo = params.Get("dateTo") + email = params.Get("email") + ) + + data := model.NewDownload(reportType, reportJournalType, reportScheduleType, reportAccountType, reportDebtType, dateOfTransaction, dateTo, dateFrom, email) + err := h.Client().Download(ctx, data) + + if err != nil { + var ( + valErr model.ValidationError + stErr api.StatusError + ) + if errors.As(err, &valErr) { + data := AppVars{ValidationErrors: RenameErrors(valErr.Errors)} + w.WriteHeader(http.StatusUnprocessableEntity) + err = h.execute(w, r, data) + } else if errors.As(err, &stErr) { + data := AppVars{Error: stErr.Error()} + w.WriteHeader(stErr.Code) + err = h.execute(w, r, data) + } + } + + return err +} diff --git a/internal/server/download_test.go b/internal/server/download_test.go new file mode 100644 index 0000000..885484b --- /dev/null +++ b/internal/server/download_test.go @@ -0,0 +1,99 @@ +package server + +import ( + "github.com/opg-sirius-finance-admin/internal/api" + "github.com/opg-sirius-finance-admin/internal/model" + "github.com/stretchr/testify/assert" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" +) + +func TestDownloadSuccess(t *testing.T) { + form := url.Values{ + "reportType": {"AccountsReceivable"}, + "reportJournalType": {""}, + "reportScheduleType": {""}, + "reportAccountType": {"BadDebtWriteOffReport"}, + "reportDebtType": {""}, + "dateOfTransaction": {"11/05/2024"}, + "dateFrom": {"01/04/2024"}, + "dateTo": {"31/03/2025"}, + "email": {"SomeSortOfEmail@example.com"}, + } + + client := mockApiClient{} + ro := &mockRoute{client: client} + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodPost, "/download", strings.NewReader(form.Encode())) + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + appVars := AppVars{ + Path: "/download", + } + + appVars.EnvironmentVars.Prefix = "prefix" + + sut := GetDownloadHandler{ro} + + err := sut.render(appVars, w, r) + + assert.Nil(t, err) +} + +func TestDownloadValidationErrors(t *testing.T) { + assert := assert.New(t) + client := &mockApiClient{} + ro := &mockRoute{client: client} + + validationErrors := model.ValidationErrors{ + "ReportType": { + "ReportType": "Please select a report type", + }, + } + + client.error = model.ValidationError{ + Errors: validationErrors, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/download", nil) + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + appVars := AppVars{ + Path: "/add", + } + + sut := GetDownloadHandler{ro} + err := sut.render(appVars, w, r) + assert.Nil(err) + assert.Equal("422 Unprocessable Entity", w.Result().Status) +} + +func TestDownloadStatusError(t *testing.T) { + assert := assert.New(t) + client := &mockApiClient{} + ro := &mockRoute{client: client} + + client.error = api.StatusError{ + Code: http.StatusInternalServerError, + URL: "/downloads", + Method: http.MethodGet, + } + + w := httptest.NewRecorder() + r, _ := http.NewRequest(http.MethodGet, "/download", nil) + r.Header.Add("Content-Type", "application/x-www-form-urlencoded") + + appVars := AppVars{ + Path: "/add", + } + + sut := GetDownloadHandler{ro} + err := sut.render(appVars, w, r) + assert.Nil(err) + assert.Equal(http.StatusInternalServerError, w.Result().StatusCode) +} diff --git a/internal/server/environment_vars.go b/internal/server/environment_vars.go index dea975a..6f4f16f 100644 --- a/internal/server/environment_vars.go +++ b/internal/server/environment_vars.go @@ -9,6 +9,7 @@ type EnvironmentVars struct { WebDir string SiriusURL string SiriusPublicURL string + BackendUrl string Prefix string } @@ -19,6 +20,7 @@ func NewEnvironmentVars() EnvironmentVars { SiriusURL: getEnv("SIRIUS_URL", "http://host.docker.internal:8080"), SiriusPublicURL: getEnv("SIRIUS_PUBLIC_URL", ""), Prefix: getEnv("PREFIX", ""), + BackendUrl: getEnv("BACKEND_URL", ""), } } diff --git a/internal/server/get_downloads.go b/internal/server/get_downloads.go index 7aff9f4..6945e01 100644 --- a/internal/server/get_downloads.go +++ b/internal/server/get_downloads.go @@ -1,10 +1,16 @@ package server import ( + "github.com/opg-sirius-finance-admin/internal/model" "net/http" ) type GetDownloadsVars struct { + ReportsTypes []model.ReportsType + ReportJournalTypes []model.ReportJournalType + ReportScheduleTypes []model.ReportScheduleType + ReportAccountTypes []model.ReportAccountType + ReportDebtTypes []model.ReportDebtType AppVars } @@ -13,7 +19,13 @@ type GetDownloadsHandler struct { } func (h *GetDownloadsHandler) render(v AppVars, w http.ResponseWriter, r *http.Request) error { - data := GetDownloadsVars{v} + data := GetDownloadsVars{ + model.ReportsTypes, + model.ReportJournalTypes, + model.ReportScheduleTypes, + model.ReportAccountTypes, + model.ReportDebtTypes, + v} data.selectTab("downloads") return h.execute(w, r, data) } diff --git a/internal/server/route_test.go b/internal/server/route_test.go index f9eef06..f137007 100644 --- a/internal/server/route_test.go +++ b/internal/server/route_test.go @@ -42,7 +42,6 @@ func TestRoute_fullPage(t *testing.T) { w := httptest.NewRecorder() r, _ := http.NewRequest(http.MethodGet, "", nil) - r.SetPathValue("clientId", "1") data := PageData{ Data: mockRouteData{ diff --git a/internal/server/server.go b/internal/server/server.go index 2564989..1157e6c 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -3,14 +3,19 @@ package server import ( "github.com/ministryofjustice/opg-go-common/securityheaders" "github.com/ministryofjustice/opg-go-common/telemetry" + "github.com/opg-sirius-finance-admin/internal/api" + "github.com/opg-sirius-finance-admin/internal/model" "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp" "html/template" "io" "log/slog" "net/http" + "net/url" ) -type ApiClient interface{} +type ApiClient interface { + Download(api.Context, model.Download) error +} type router interface { Client() ApiClient @@ -35,6 +40,8 @@ func New(logger *slog.Logger, client ApiClient, templates map[string]*template.T handleMux("GET /uploads", &GetUploadsHandler{&route{client: client, tmpl: templates["uploads.gotmpl"], partial: "uploads"}}) handleMux("GET /annual-invoicing-letters", &GetAnnualInvoicingLettersHandler{&route{client: client, tmpl: templates["annual_invoicing_letters.gotmpl"], partial: "annual-invoicing-letters"}}) + handleMux("GET /download", &GetDownloadHandler{&route{client: client, tmpl: templates["downloads.gotmpl"], partial: "error-summary"}}) + mux.Handle("/health-check", healthCheck()) static := http.FileServer(http.Dir(envVars.WebDir + "/static")) @@ -44,3 +51,21 @@ func New(logger *slog.Logger, client ApiClient, templates map[string]*template.T return otelhttp.NewHandler(http.StripPrefix(envVars.Prefix, securityheaders.Use(mux)), "supervision-finance-admin") } + +func getContext(r *http.Request) api.Context { + token := "" + + if r.Method == http.MethodGet { + if cookie, err := r.Cookie("XSRF-TOKEN"); err == nil { + token, _ = url.QueryUnescape(cookie.Value) + } + } else { + token = r.FormValue("xsrfToken") + } + + return api.Context{ + Context: r.Context(), + Cookies: r.Cookies(), + XSRFToken: token, + } +} diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 0946243..3e6a7f0 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -1,6 +1,8 @@ package server import ( + "github.com/opg-sirius-finance-admin/internal/api" + "github.com/opg-sirius-finance-admin/internal/model" "io" "net/http" ) @@ -49,3 +51,7 @@ func (r *mockRoute) execute(w http.ResponseWriter, req *http.Request, data any) type mockApiClient struct { error error //nolint:golint,unused } + +func (m mockApiClient) Download(context api.Context, data model.Download) error { + return m.error +} diff --git a/internal/server/translate.go b/internal/server/translate.go new file mode 100644 index 0000000..cbb2a56 --- /dev/null +++ b/internal/server/translate.go @@ -0,0 +1,44 @@ +package server + +import "github.com/opg-sirius-finance-admin/internal/model" + +type pair struct { + k string + v string +} + +var validationMappings = map[string]map[string]pair{ + "ReportType": { + "required": pair{"ReportType", "Please select a report type"}, + }, + "ReportSubType": { + "required": pair{"ReportSubType", "Please select a report to download"}, + }, + "Date": { + "Date": pair{"Date", "Please select the report date"}, + "date-in-the-past": pair{"Date", "The report date must be today or in the past"}, + }, + "FromDate": { + "FromDate": pair{"FromDate", "Date From must be before Date To"}, + }, + "ToDate": { + "ToDate": pair{"ToDate", "Date To must be after Date From"}, + }, +} + +func RenameErrors(siriusError model.ValidationErrors) model.ValidationErrors { + mappedErrors := model.ValidationErrors{} + for fieldName, value := range siriusError { + for errorType, errorMessage := range value { + err := make(map[string]string) + if mapping, ok := validationMappings[fieldName][errorType]; ok { + err[errorType] = mapping.v + mappedErrors[mapping.k] = err + } else { + err[errorType] = errorMessage + mappedErrors[fieldName] = err + } + } + } + return mappedErrors +} diff --git a/internal/server/translate_test.go b/internal/server/translate_test.go new file mode 100644 index 0000000..350e70d --- /dev/null +++ b/internal/server/translate_test.go @@ -0,0 +1,28 @@ +package server + +import ( + "github.com/opg-sirius-finance-admin/internal/model" + "github.com/stretchr/testify/assert" + "testing" +) + +func TestRenameErrors(t *testing.T) { + siriusErrors := model.ValidationErrors{ + "ReportType": map[string]string{"required": ""}, + "Date": map[string]string{"Date": ""}, + } + expected := model.ValidationErrors{ + "ReportType": map[string]string{"required": "Please select a report type"}, + "Date": map[string]string{"Date": "Please select the report date"}, + } + + assert.Equal(t, expected, RenameErrors(siriusErrors)) +} + +func TestRenameErrors_default(t *testing.T) { + siriusErrors := model.ValidationErrors{ + "x": map[string]string{"y": "z"}, + } + + assert.Equal(t, siriusErrors, RenameErrors(siriusErrors)) +} diff --git a/main.go b/main.go index 3c3f6bb..b33c853 100644 --- a/main.go +++ b/main.go @@ -39,7 +39,10 @@ func run(ctx context.Context, logger *slog.Logger) error { } envVars := server.NewEnvironmentVars() - client := api.NewApiClient(http.DefaultClient) + client, err := api.NewClient(http.DefaultClient, envVars.SiriusURL, envVars.BackendUrl) + if err != nil { + return err + } templates := createTemplates(envVars) s := &http.Server{ diff --git a/web/assets/main.js b/web/assets/main.js index 9440380..1e47943 100644 --- a/web/assets/main.js +++ b/web/assets/main.js @@ -9,3 +9,124 @@ initAll(); window.htmx = htmx htmx.logAll(); htmx.config.responseHandling = [{code:".*", swap: true}] + +function disableFormInputs() { + document.querySelector('#report-journal-type').setAttribute("disabled", "true") + document.querySelector('#report-schedule-type').setAttribute("disabled", "true") + document.querySelector('#report-account-type').setAttribute("disabled", "true") + document.querySelector('#report-debt-type').setAttribute("disabled", "true") + document.querySelector('#date-field').setAttribute("disabled", "true") + document.querySelector('#date-from-field').setAttribute("disabled", "true") + document.querySelector('#date-to-field').setAttribute("disabled", "true") + document.querySelector('#email-field').setAttribute("disabled", "true") +} + +// adding event listeners inside the onLoad function will ensure they are re-added to partial content when loaded back in +htmx.onLoad(content => { + initAll(); + + if (document.getElementById('reports-type')) { + htmx.findAll("#reports-type").forEach((element) => { + element.addEventListener("change", function() { + const elements = document.querySelectorAll('[id$="-field-input"]'); + elements.forEach(element => { + htmx.addClass(element, 'hide'); + }); + disableFormInputs(); + const form = document.querySelector('form'); + const reportTypeSelect = document.getElementById('reports-type'); + const reportTypeSelectValue = reportTypeSelect.value + + form.reset(); + reportTypeSelect.value = reportTypeSelectValue + + switch (reportTypeSelect.value) { + case "Journal": + document.querySelector('#report-journal-type').removeAttribute("disabled"); + document.querySelector('#date-field').removeAttribute("disabled"); + htmx.removeClass(htmx.find("#journal-types-field-input"), "hide") + htmx.removeClass(htmx.find("#date-field-input"), "hide") + break; + case "Schedule": + document.querySelector('#report-schedule-type').removeAttribute("disabled"); + document.querySelector('#date-field').removeAttribute("disabled"); + htmx.removeClass(htmx.find("#schedule-types-field-input"), "hide") + htmx.removeClass(htmx.find("#date-field-input"), "hide") + break; + case "AccountsReceivable": + document.querySelector('#report-account-type').removeAttribute("disabled") + htmx.removeClass(htmx.find("#account-types-field-input"), "hide") + break; + case "Debt": + document.querySelector('#report-debt-type').removeAttribute("disabled") + htmx.removeClass(htmx.find("#debt-types-field-input"), "hide") + break; + default: + break; + } + }, false) + }); + + document.getElementById('report-account-type').addEventListener('change', function () { + const form = document.querySelector('form'); + const reportTypeSelect = document.getElementById('reports-type'); + const reportTypeSelectValue = reportTypeSelect.value + const reportAccountTypeSelectValue = this.value + disableFormInputs(); + document.querySelector('#report-account-type').removeAttribute("disabled"); + + form.reset(); + reportTypeSelect.value = reportTypeSelectValue + this.value = reportAccountTypeSelectValue + + switch (this.value) { + case "AgedDebt": + case "UnappliedReceipts": + case "CustomerAgeingBuckets": + document.querySelector('#date-field').removeAttribute("disabled"); + htmx.addClass(htmx.find("#date-to-field-input"), "hide") + htmx.addClass(htmx.find("#email-field-input"), "hide") + htmx.addClass(htmx.find("#date-from-field-input"), "hide") + htmx.removeClass(htmx.find("#date-field-input"), "hide") + break; + case "ARPaidInvoiceReport": + case "PaidInvoiceTransactionLines": + case "TotalReceiptsReport": + case "BadDebtWriteOffReport": + document.querySelector('#date-to-field').removeAttribute("disabled"); + document.querySelector('#email-field').removeAttribute("disabled"); + document.querySelector('#date-from-field').removeAttribute("disabled"); + htmx.addClass(htmx.find("#date-field-input"), "hide") + htmx.removeClass(htmx.find("#date-to-field-input"), "hide") + htmx.removeClass(htmx.find("#email-field-input"), "hide") + htmx.removeClass(htmx.find("#date-from-field-input"), "hide") + break; + case "FeeAccrual": + htmx.addClass(htmx.find("#date-field-input"), "hide") + htmx.addClass(htmx.find("#date-to-field-input"), "hide") + htmx.addClass(htmx.find("#email-field-input"), "hide") + htmx.addClass(htmx.find("#date-from-field-input"), "hide") + break; + default: + break; + } + }) + } + + // validation errors are loaded in as a partial, with oob-swaps for the field error messages, + // but classes need to be applied to each form group that appears in the summary + const errorSummary = htmx.find("#error-summary"); + if (errorSummary) { + const errors = []; + errorSummary.querySelectorAll(".govuk-link").forEach((element) => { + errors.push(element.getAttribute("href")); + }); + htmx.findAll(".govuk-form-group").forEach((element) => { + if (errors.includes(`#${element.id}`)) { + element.classList.add("govuk-form-group--error"); + } else { + element.classList.remove("govuk-form-group--error"); + } + }) + } +}); \ No newline at end of file diff --git a/web/template/downloads.gotmpl b/web/template/downloads.gotmpl index 01c4722..c996de7 100644 --- a/web/template/downloads.gotmpl +++ b/web/template/downloads.gotmpl @@ -4,9 +4,143 @@ {{ define "main-content" }} {{ block "downloads" .Data }} {{ template "navigation" . }} -