package pkg import ( "context" "github.com/jackc/pgtype" "github.com/julienschmidt/httprouter" "html/template" "net/http" "time" ) const ( ExpirationDateFormat = "01/06" AccountTypeBank = "bank" AccountTypeCard = "card" AccountTypeCash = "cash" AccountTypeOther = "other" ) func servePaymentAccountIndex(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { conn := getConn(r) locale := getLocale(r) page := NewPaymentAccountIndexPage(r.Context(), conn, locale) page.MustRender(w, r) } type PaymentAccountIndexPage struct { Accounts []*PaymentAccountEntry } func NewPaymentAccountIndexPage(ctx context.Context, conn *Conn, locale *Locale) *PaymentAccountIndexPage { return &PaymentAccountIndexPage{ Accounts: mustCollectPaymentAccountEntries(ctx, conn, locale), } } func (page *PaymentAccountIndexPage) MustRender(w http.ResponseWriter, r *http.Request) { mustRenderMainTemplate(w, r, "payments/accounts/index.gohtml", page) } type PaymentAccountEntry struct { ID int Slug string Type string TypeLabel string Name string IBAN string LastFourDigits string ExpirationDate string } func mustCollectPaymentAccountEntries(ctx context.Context, conn *Conn, locale *Locale) []*PaymentAccountEntry { rows := conn.MustQuery(ctx, ` select payment_account_id , slug , payment_account.payment_account_type , coalesce(i18n.name, payment_account_type.name) , payment_account.name , coalesce(iban::text, '') as iban , coalesce(last_four_digits, '') as last_four_digits , expiration_date from payment_account left join payment_account_bank using (payment_account_id, payment_account_type) left join payment_account_card using (payment_account_id, payment_account_type) join payment_account_type using (payment_account_type) left join payment_account_type_i18n as i18n on payment_account_type.payment_account_type = i18n.payment_account_type and i18n.lang_tag = $1 order by payment_account_id `, locale.Language.String()) defer rows.Close() var entries []*PaymentAccountEntry for rows.Next() { entry := &PaymentAccountEntry{} var expirationDate pgtype.Date if err := rows.Scan(&entry.ID, &entry.Slug, &entry.Type, &entry.TypeLabel, &entry.Name, &entry.IBAN, &entry.LastFourDigits, &expirationDate); err != nil { panic(err) } if expirationDate.Status == pgtype.Present { entry.ExpirationDate = expirationDate.Time.Format(ExpirationDateFormat) } entries = append(entries, entry) } if rows.Err() != nil { panic(rows.Err()) } return entries } func servePaymentAccountForm(w http.ResponseWriter, r *http.Request, params httprouter.Params) { locale := getLocale(r) conn := getConn(r) company := mustGetCompany(r) form := newPaymentAccountForm(r.Context(), conn, locale, company) slug := params[0].Value if slug == "new" { form.MustRender(w, r) return } if !ValidUuid(slug) { http.NotFound(w, r) return } if !form.MustFillFromDatabase(r.Context(), conn, slug) { http.NotFound(w, r) return } form.MustRender(w, r) } type PaymentAccountForm struct { locale *Locale company *Company Slug string Type *RadioField Name *InputField IBAN *InputField LastFourDigits *InputField ExpirationMonthYear *InputField } func newPaymentAccountForm(ctx context.Context, conn *Conn, locale *Locale, company *Company) *PaymentAccountForm { return &PaymentAccountForm{ locale: locale, company: company, Type: &RadioField{ Name: "payment_account_type", Label: pgettext("input", "Type", locale), Required: true, Options: MustGetRadioOptions(ctx, conn, "select payment_account_type, i18n.name from payment_account_type join payment_account_type_i18n as i18n using (payment_account_type) where i18n.lang_tag = $1 order by payment_account_type", locale.Language.String()), Attributes: []template.HTMLAttr{ `x-model="type"`, }, }, Name: &InputField{ Name: "name", Label: pgettext("input", "Name", locale), Required: true, Type: "text", }, IBAN: &InputField{ Name: "iban", Label: pgettext("input", "IBAN", locale), Required: true, Type: "text", }, LastFourDigits: &InputField{ Name: "last_four_digits", Label: pgettext("input", "Card’s last four digits", locale), Required: true, Type: "text", Attributes: []template.HTMLAttr{ `maxlength="4"`, `minlength="4"`, `pattern="[0-9]{4}"`, }, }, ExpirationMonthYear: &InputField{ Name: "expiration_date", Label: pgettext("input", "Expiration date", locale), Required: true, Type: "text", Attributes: []template.HTMLAttr{ `maxlength="5"`, `minlength="5"`, `pattern="[0-9]{2}/[0-9]{2}"`, }, }, } } func (f *PaymentAccountForm) MustRender(w http.ResponseWriter, r *http.Request) { if f.Slug == "" { mustRenderMainTemplate(w, r, "payments/accounts/new.gohtml", f) } else { mustRenderMainTemplate(w, r, "payments/accounts/edit.gohtml", f) } } func (f *PaymentAccountForm) MustFillFromDatabase(ctx context.Context, conn *Conn, slug string) bool { selectedType := f.Type.Selected var expirationDate pgtype.Date if notFoundErrorOrPanic(conn.QueryRow(ctx, ` select payment_account_type , name , coalesce(iban::text, '') as iban , coalesce(last_four_digits, '') as last_four_digits , expiration_date from payment_account left join payment_account_bank using (payment_account_id, payment_account_type) left join payment_account_card using (payment_account_id, payment_account_type) where slug = $1 `, slug).Scan( f.Type, f.Name, f.IBAN, f.LastFourDigits, &expirationDate)) { f.Type.Selected = selectedType return false } f.Slug = slug if expirationDate.Status == pgtype.Present { f.ExpirationMonthYear.Val = expirationDate.Time.Format(ExpirationDateFormat) } else { f.ExpirationMonthYear.Val = "" } return true } func (f *PaymentAccountForm) Parse(r *http.Request) error { if err := r.ParseForm(); err != nil { return err } f.Type.FillValue(r) f.Name.FillValue(r) f.IBAN.FillValue(r) f.LastFourDigits.FillValue(r) f.ExpirationMonthYear.FillValue(r) return nil } func (f *PaymentAccountForm) Validate(ctx context.Context, conn *Conn) bool { validator := newFormValidator() if validator.CheckValidRadioOption(f.Type, gettext("Selected payment account type is not valid.", f.locale)) { switch f.Type.Selected { case AccountTypeBank: if validator.CheckRequiredInput(f.IBAN, gettext("IBAN can not be empty.", f.locale)) { validator.CheckValidIBANInput(ctx, conn, f.IBAN, gettext("This value is not a valid IBAN.", f.locale)) } case AccountTypeCard: if validator.CheckRequiredInput(f.LastFourDigits, gettext("Last four digits can not be empty.", f.locale)) { if validator.CheckInputLength(f.LastFourDigits, 4, gettext("You must enter the card’s last four digits", f.locale)) { validator.CheckValidInteger(f.LastFourDigits, 0, 9999, gettext("Last four digits must be a number.", f.locale)) } } if validator.CheckRequiredInput(f.ExpirationMonthYear, gettext("Expiration date can not be empty.", f.locale)) { _, err := time.Parse(ExpirationDateFormat, f.ExpirationMonthYear.Val) validator.checkInput(f.ExpirationMonthYear, err == nil, gettext("Expiration date should be a valid date in format MM/YY (e.g., 08/24).", f.locale)) } } } validator.CheckRequiredInput(f.Name, gettext("Payment account name can not be empty.", f.locale)) return validator.AllOK() } func (f *PaymentAccountForm) ExpirationDate() (time.Time, error) { date, err := time.Parse(ExpirationDateFormat, f.ExpirationMonthYear.Val) if err != nil { return date, err } return date.AddDate(0, 1, -1), nil } func handleAddPaymentAccount(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { locale := getLocale(r) conn := getConn(r) company := mustGetCompany(r) form := newPaymentAccountForm(r.Context(), conn, locale, company) if err := form.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := verifyCsrfTokenValid(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } if !form.Validate(r.Context(), conn) { if !IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } form.MustRender(w, r) return } switch form.Type.Selected { case AccountTypeBank: conn.MustExec(r.Context(), "select add_payment_account_bank($1, $2, $3)", company.Id, form.Name, form.IBAN) case AccountTypeCard: date, err := form.ExpirationDate() if err != nil { panic(err) } conn.MustExec(r.Context(), "select add_payment_account_card($1, $2, $3, $4)", company.Id, form.Name, form.LastFourDigits, date) case AccountTypeCash: conn.MustExec(r.Context(), "select add_payment_account_cash($1, $2)", company.Id, form.Name) case AccountTypeOther: conn.MustExec(r.Context(), "select add_payment_account_other($1, $2)", company.Id, form.Name) } htmxRedirect(w, r, companyURI(company, "/payment-accounts")) } func handleEditPaymentAccount(w http.ResponseWriter, r *http.Request, params httprouter.Params) { conn := getConn(r) locale := getLocale(r) company := mustGetCompany(r) form := newPaymentAccountForm(r.Context(), conn, locale, company) form.Slug = params[0].Value if !ValidUuid(form.Slug) { http.NotFound(w, r) return } if err := form.Parse(r); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } if err := verifyCsrfTokenValid(r); err != nil { http.Error(w, err.Error(), http.StatusForbidden) return } if !form.Validate(r.Context(), conn) { if !IsHTMxRequest(r) { w.WriteHeader(http.StatusUnprocessableEntity) } form.MustRender(w, r) return } var found string switch form.Type.Selected { case AccountTypeBank: found = conn.MustGetText(r.Context(), "", "select edit_payment_account_bank($1, $2, $3)", form.Slug, form.Name, form.IBAN) case AccountTypeCard: date, err := form.ExpirationDate() if err != nil { panic(err) } found = conn.MustGetText(r.Context(), "", "select edit_payment_account_card($1, $2, $3, $4)", form.Slug, form.Name, form.LastFourDigits, date) case AccountTypeCash: found = conn.MustGetText(r.Context(), "", "select edit_payment_account_cash($1, $2)", form.Slug, form.Name) case AccountTypeOther: found = conn.MustGetText(r.Context(), "", "select edit_payment_account_other($1, $2)", form.Slug, form.Name) } if found == "" { http.NotFound(w, r) return } htmxRedirect(w, r, companyURI(company, "/payment-accounts")) }