// openpayments-mock is a tiny in-memory stand-in for the Open Payments // PSD2/BerlinGroup aggregator, built for acceptance tests. It implements // just enough of the real API surface that banking-service can drive the // full connect → sync → match → pay flow end-to-end, and exposes admin // endpoints that the Robot Framework suite calls to deterministically // drive mock state (force payment statuses, seed transactions, override // consent expiries, reset between suites). // // Not safe for anything but tests. Accepts any bearer token. Holds all // state in memory. package main import ( "encoding/json" "fmt" "log/slog" "net/http" "net/url" "os" "strings" "sync" "time" "github.com/google/uuid" ) // --------------------------------------------------------------------- // Domain shapes matching the relevant subset of BerlinGroup / Open Payments. // --------------------------------------------------------------------- type aspsp struct { BicFi string `json:"bicFi"` Name string `json:"name"` LogoURL string `json:"logoUrl,omitempty"` Country string `json:"country"` SupportedServices map[string]bool `json:"supportedServices"` } type amount struct { Currency string `json:"currency"` Amount string `json:"amount"` } type accountRef struct { IBAN string `json:"iban,omitempty"` Currency string `json:"currency,omitempty"` } type balance struct { BalanceAmount amount `json:"balanceAmount"` BalanceType string `json:"balanceType"` ReferenceDate string `json:"referenceDate,omitempty"` } type account struct { ResourceID string `json:"resourceId"` IBAN string `json:"iban,omitempty"` BIC string `json:"bic,omitempty"` Currency string `json:"currency"` Name string `json:"name,omitempty"` Product string `json:"product,omitempty"` Status string `json:"status,omitempty"` Balances []balance `json:"balances,omitempty"` } type transaction struct { TransactionID string `json:"transactionId,omitempty"` EndToEndID string `json:"endToEndId,omitempty"` BookingDate string `json:"bookingDate,omitempty"` ValueDate string `json:"valueDate,omitempty"` TransactionAmount amount `json:"transactionAmount"` CreditorName string `json:"creditorName,omitempty"` CreditorAccount accountRef `json:"creditorAccount,omitempty"` DebtorName string `json:"debtorName,omitempty"` DebtorAccount accountRef `json:"debtorAccount,omitempty"` RemittanceInfo string `json:"remittanceInformationUnstructured,omitempty"` BookingStatus string `json:"bookingStatus,omitempty"` PurposeCode string `json:"purposeCode,omitempty"` BankTransactionCode string `json:"bankTransactionCode,omitempty"` } type consent struct { ID string Status string ExpiresAt time.Time CreatedAt time.Time BicFi string RedirectTo string } type payment struct { ID string Service string Product string Status string StatusQueue []string // deterministic transitions CreditorName string InstructedAmt amount BasketID string } type basket struct { ID string Status string StatusQueue []string PaymentIDs []string } // --------------------------------------------------------------------- // Mock server state. // --------------------------------------------------------------------- type server struct { mu sync.Mutex aspsps []aspsp // consent/account state (single stand-in customer per bicFi) consents map[string]*consent // consentId -> consent // accounts keyed by resourceId accounts map[string]account // transactions keyed by accountId → list (may be empty) transactions map[string][]transaction // payments keyed by paymentId payments map[string]*payment // baskets keyed by basketId baskets map[string]*basket logger *slog.Logger } func newServer(logger *slog.Logger) *server { s := &server{ logger: logger, consents: map[string]*consent{}, accounts: map[string]account{}, transactions: map[string][]transaction{}, payments: map[string]*payment{}, baskets: map[string]*basket{}, } s.seedDefaults() return s } // seedDefaults populates the catalog + default account so an un-touched // mock is already useful. func (s *server) seedDefaults() { s.aspsps = []aspsp{ { BicFi: "SHINYSESS", Name: "Shiny Test Bank", LogoURL: "", Country: "SE", SupportedServices: map[string]bool{ "ais": true, "pis": true, "cardAccounts": false, "signingBasketSupported": true, "multiLevelScaSupported": false, "instantPaymentSupported": true, }, }, { BicFi: "MOCKSESSXXX", Name: "Mockbank Sweden", LogoURL: "", Country: "SE", SupportedServices: map[string]bool{ "ais": true, "pis": true, "cardAccounts": false, "signingBasketSupported": true, "multiLevelScaSupported": false, "instantPaymentSupported": false, }, }, { BicFi: "DEMOSESSXXX", Name: "Demobank AB", LogoURL: "", Country: "SE", SupportedServices: map[string]bool{ "ais": true, "pis": false, "cardAccounts": false, "signingBasketSupported": false, "multiLevelScaSupported": false, "instantPaymentSupported": false, }, }, } s.accounts["acc-1"] = account{ ResourceID: "acc-1", IBAN: "SE1234567890", BIC: "SHINYSESS", Currency: "SEK", Name: "Payment Account", Product: "CURRENT", Status: "enabled", Balances: []balance{ { BalanceAmount: amount{Currency: "SEK", Amount: "10000.00"}, BalanceType: "closingBooked", ReferenceDate: time.Now().UTC().Format("2006-01-02"), }, }, } } // reset clears everything except the default catalog / accounts. func (s *server) reset() { s.mu.Lock() defer s.mu.Unlock() s.consents = map[string]*consent{} s.transactions = map[string][]transaction{} s.payments = map[string]*payment{} s.baskets = map[string]*basket{} // Keep aspsps + accounts seeded — most tests expect them. s.accounts = map[string]account{} s.seedDefaults() } // --------------------------------------------------------------------- // HTTP helpers. // --------------------------------------------------------------------- func writeJSON(w http.ResponseWriter, status int, payload any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(payload) } func writeErr(w http.ResponseWriter, status int, code, text string) { writeJSON(w, status, map[string]any{ "tppMessages": []map[string]string{ {"category": "ERROR", "code": code, "text": text}, }, }) } func (s *server) withCORS(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET,POST,PUT,DELETE,OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "*") if r.Method == http.MethodOptions { w.WriteHeader(http.StatusNoContent) return } next(w, r) } } // --------------------------------------------------------------------- // OAuth token: accept any client, return static bearer. // --------------------------------------------------------------------- func (s *server) handleToken(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "access_token": "acctest", "token_type": "Bearer", "expires_in": 3600, }) } // --------------------------------------------------------------------- // AIS: ASPSP catalog, accounts, balances, transactions. // --------------------------------------------------------------------- func (s *server) handleASPSPs(w http.ResponseWriter, r *http.Request) { s.mu.Lock() list := make([]aspsp, len(s.aspsps)) copy(list, s.aspsps) s.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"aspsps": list}) } // handleCreateConsent returns a consentId + an scaOAuth link the frontend // is supposed to redirect to. The link points at our own `/authorize` // stub which in turn immediately redirects back to the TPP-Redirect-URI. func (s *server) handleCreateConsent(w http.ResponseWriter, r *http.Request) { redirect := r.Header.Get("TPP-Redirect-URI") bicFi := r.Header.Get("X-BicFi") if redirect == "" { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "TPP-Redirect-URI header is required") return } id := uuid.NewString() c := &consent{ ID: id, Status: "received", ExpiresAt: time.Now().UTC().AddDate(0, 0, 90), CreatedAt: time.Now().UTC(), BicFi: bicFi, RedirectTo: redirect, } s.mu.Lock() s.consents[id] = c s.mu.Unlock() sca := buildSCALink(r, "consent", id, redirect) writeJSON(w, http.StatusCreated, map[string]any{ "consentStatus": "received", "consentId": id, "_links": map[string]any{ "scaOAuth": map[string]string{"href": sca}, "status": map[string]string{"href": fmt.Sprintf("/psd2/consent/v1/consents/%s/status", id)}, }, }) } func (s *server) handleConsentStatus(w http.ResponseWriter, r *http.Request) { id := pathSuffix(r.URL.Path, "/psd2/consent/v1/consents/", "/status") s.mu.Lock() c, ok := s.consents[id] s.mu.Unlock() if !ok { writeJSON(w, http.StatusOK, map[string]string{"consentStatus": "valid"}) return } writeJSON(w, http.StatusOK, map[string]string{"consentStatus": c.Status}) } func (s *server) handleDeleteConsent(w http.ResponseWriter, r *http.Request) { id := strings.TrimPrefix(r.URL.Path, "/psd2/consent/v1/consents/") s.mu.Lock() if c, ok := s.consents[id]; ok { c.Status = "terminatedByTpp" } s.mu.Unlock() w.WriteHeader(http.StatusNoContent) } func (s *server) handleAuthorize(w http.ResponseWriter, r *http.Request) { // This is the mock's own SCA stub — not a real BerlinGroup endpoint. // It immediately bounces the browser back to the TPP-Redirect-URI // captured when the consent/payment/basket was created, passing a // stub auth code + echoed state. Before bouncing, promote any // pending consent to "valid" so the downstream completeConnection // call finds a usable consent. q := r.URL.Query() state := q.Get("state") kind := q.Get("kind") id := q.Get("id") redirect := q.Get("redirect") if kind == "consent" && id != "" { s.mu.Lock() if c, ok := s.consents[id]; ok { c.Status = "valid" } s.mu.Unlock() } if redirect == "" { // Shouldn't happen — caller always passes one through. Fall back // to a JSON response so the test at least has something to // assert against. writeJSON(w, http.StatusOK, map[string]string{"code": "mock-auth-code", "state": state}) return } u, err := url.Parse(redirect) if err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "malformed redirect") return } qp := u.Query() qp.Set("code", "mock-auth-code") if state != "" { qp.Set("state", state) } u.RawQuery = qp.Encode() http.Redirect(w, r, u.String(), http.StatusFound) } // buildSCALink synthesises the scaOAuth href the caller should navigate // to. Points at the mock's `/authorize` stub and carries the kind/id so // the stub knows which consent/payment/basket to promote on redirect. // // `PUBLIC_BASE_URL` overrides the link base so the frontend can reach // the mock through the acctest cluster's ingress (e.g. // https://openpayments-mock), while banking-service itself calls in // via cluster-DNS http://openpayments-mock:8080. func buildSCALink(r *http.Request, kind, id, redirect string) string { base := os.Getenv("PUBLIC_BASE_URL") if base == "" { host := r.Host if host == "" { host = "openpayments-mock:8080" } scheme := "http" if r.TLS != nil { scheme = "https" } base = fmt.Sprintf("%s://%s", scheme, host) } q := url.Values{} q.Set("kind", kind) q.Set("id", id) q.Set("redirect", redirect) return base + "/authorize?" + q.Encode() } func (s *server) handleAccounts(w http.ResponseWriter, r *http.Request) { s.mu.Lock() out := make([]account, 0, len(s.accounts)) for _, a := range s.accounts { out = append(out, a) } s.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"accounts": out}) } func (s *server) handleBalances(w http.ResponseWriter, r *http.Request) { // /psd2/accountinformation/v1/accounts/{id}/balances id := pathSuffix(r.URL.Path, "/psd2/accountinformation/v1/accounts/", "/balances") s.mu.Lock() a, ok := s.accounts[id] s.mu.Unlock() if !ok { writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "account not found") return } writeJSON(w, http.StatusOK, map[string]any{ "account": accountRef{IBAN: a.IBAN, Currency: a.Currency}, "balances": a.Balances, }) } func (s *server) handleTransactions(w http.ResponseWriter, r *http.Request) { // /psd2/accountinformation/v1/accounts/{id}/transactions id := pathSuffix(r.URL.Path, "/psd2/accountinformation/v1/accounts/", "/transactions") s.mu.Lock() a, ok := s.accounts[id] all := s.transactions[id] s.mu.Unlock() if !ok { writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "account not found") return } booked := []transaction{} pending := []transaction{} for _, t := range all { switch strings.ToLower(t.BookingStatus) { case "pending": pending = append(pending, t) default: booked = append(booked, t) } } writeJSON(w, http.StatusOK, map[string]any{ "account": accountRef{IBAN: a.IBAN, Currency: a.Currency}, "transactions": map[string]any{ "booked": booked, "pending": pending, }, }) } func (s *server) handleCardAccounts(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{"cardAccounts": []any{}}) } // --------------------------------------------------------------------- // PIS: payment initiation + status transitions. // --------------------------------------------------------------------- func (s *server) handleInitiatePayment(w http.ResponseWriter, r *http.Request) { var req struct { EndToEndID string `json:"endToEndIdentification"` DebtorAccount accountRef `json:"debtorAccount"` InstructedAmount amount `json:"instructedAmount"` CreditorAccount accountRef `json:"creditorAccount"` CreditorName string `json:"creditorName"` RemittanceInformationUnstructured string `json:"remittanceInformationUnstructured,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error()) return } redirect := r.Header.Get("TPP-Redirect-URI") id := uuid.NewString() p := &payment{ ID: id, Service: "payments", Product: "sepa-credit-transfers", Status: "RCVD", CreditorName: req.CreditorName, InstructedAmt: req.InstructedAmount, } // Default deterministic transitions: RCVD (current) → PDNG → ACSC. // Special creditor name "REJECT ME" makes the payment fail. if strings.EqualFold(req.CreditorName, "REJECT ME") { p.StatusQueue = []string{"PDNG", "RJCT"} } else { p.StatusQueue = []string{"PDNG", "ACSC"} } s.mu.Lock() s.payments[id] = p s.mu.Unlock() sca := buildSCALink(r, "payment", id, redirect) writeJSON(w, http.StatusCreated, map[string]any{ "transactionStatus": "RCVD", "paymentId": id, "_links": map[string]any{ "scaOAuth": map[string]string{"href": sca}, "status": map[string]string{"href": fmt.Sprintf("/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/%s/status", id)}, }, }) } func (s *server) handlePaymentStatus(w http.ResponseWriter, r *http.Request) { // .../payments/sepa-credit-transfers/{id}/status id := pathSuffix(r.URL.Path, "/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/", "/status") s.mu.Lock() p, ok := s.payments[id] if !ok { s.mu.Unlock() writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "payment not found") return } // Advance one transition per call until the queue is empty. if len(p.StatusQueue) > 0 { p.Status = p.StatusQueue[0] p.StatusQueue = p.StatusQueue[1:] } status := p.Status msg := "" if status == "RJCT" { msg = "insufficient funds" } s.mu.Unlock() resp := map[string]any{"transactionStatus": status} if msg != "" { resp["psuMessage"] = msg } writeJSON(w, http.StatusOK, resp) } func (s *server) handleCreateBasket(w http.ResponseWriter, r *http.Request) { var req struct { PaymentIDs []string `json:"paymentIds"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error()) return } if len(req.PaymentIDs) == 0 { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "paymentIds must not be empty") return } redirect := r.Header.Get("TPP-Redirect-URI") id := uuid.NewString() b := &basket{ ID: id, Status: "RCVD", StatusQueue: []string{"ACCP", "ACSC"}, PaymentIDs: req.PaymentIDs, } s.mu.Lock() s.baskets[id] = b // Link basket id onto each payment for later lookups. for _, pid := range req.PaymentIDs { if p, ok := s.payments[pid]; ok { p.BasketID = id } } s.mu.Unlock() sca := buildSCALink(r, "basket", id, redirect) writeJSON(w, http.StatusCreated, map[string]any{ "transactionStatus": "RCVD", "basketId": id, "_links": map[string]any{ "scaOAuth": map[string]string{"href": sca}, "status": map[string]string{"href": fmt.Sprintf("/psd2/v1/signing-baskets/%s/status", id)}, }, }) } func (s *server) handleBasketStatus(w http.ResponseWriter, r *http.Request) { // /psd2/v1/signing-baskets/{id}/status id := pathSuffix(r.URL.Path, "/psd2/v1/signing-baskets/", "/status") s.mu.Lock() b, ok := s.baskets[id] if !ok { s.mu.Unlock() writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "basket not found") return } if len(b.StatusQueue) > 0 { b.Status = b.StatusQueue[0] b.StatusQueue = b.StatusQueue[1:] // When the basket reaches ACSC, also advance all its payments // to ACSC on their next poll. if b.Status == "ACSC" { for _, pid := range b.PaymentIDs { if p, ok := s.payments[pid]; ok { p.StatusQueue = []string{"ACSC"} } } } } status := b.Status s.mu.Unlock() writeJSON(w, http.StatusOK, map[string]any{"transactionStatus": status}) } // --------------------------------------------------------------------- // Admin endpoints — not part of Open Payments. The acctest suite calls // these directly to drive state. // --------------------------------------------------------------------- func (s *server) handleAdminReset(w http.ResponseWriter, r *http.Request) { s.reset() w.WriteHeader(http.StatusNoContent) } func (s *server) handleAdminForcePaymentStatus(w http.ResponseWriter, r *http.Request) { // POST /admin/payments/{id}/status { "status": "ACSC" } id := pathSuffix(r.URL.Path, "/admin/payments/", "/status") var body struct { Status string `json:"status"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error()) return } s.mu.Lock() defer s.mu.Unlock() p, ok := s.payments[id] if !ok { writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "payment not found") return } p.Status = body.Status p.StatusQueue = nil w.WriteHeader(http.StatusNoContent) } func (s *server) handleAdminForceBasketStatus(w http.ResponseWriter, r *http.Request) { id := pathSuffix(r.URL.Path, "/admin/baskets/", "/status") var body struct { Status string `json:"status"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error()) return } s.mu.Lock() defer s.mu.Unlock() b, ok := s.baskets[id] if !ok { writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "basket not found") return } b.Status = body.Status b.StatusQueue = nil if body.Status == "ACSC" { for _, pid := range b.PaymentIDs { if p, ok := s.payments[pid]; ok { p.Status = "ACSC" p.StatusQueue = nil } } } w.WriteHeader(http.StatusNoContent) } func (s *server) handleAdminSeedTransactions(w http.ResponseWriter, r *http.Request) { // POST /admin/transactions // { "accountId": "acc-1", "bookingStatus": "booked", "entries": [...] } var body struct { AccountID string `json:"accountId"` BookingStatus string `json:"bookingStatus"` Entries []transaction `json:"entries"` Replace bool `json:"replace"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error()) return } if body.AccountID == "" { body.AccountID = "acc-1" } if body.BookingStatus == "" { body.BookingStatus = "booked" } for i := range body.Entries { if body.Entries[i].BookingStatus == "" { body.Entries[i].BookingStatus = body.BookingStatus } if body.Entries[i].TransactionID == "" { body.Entries[i].TransactionID = uuid.NewString() } } s.mu.Lock() if body.Replace { s.transactions[body.AccountID] = body.Entries } else { s.transactions[body.AccountID] = append(s.transactions[body.AccountID], body.Entries...) } s.mu.Unlock() w.WriteHeader(http.StatusNoContent) } func (s *server) handleAdminConsentExpiry(w http.ResponseWriter, r *http.Request) { // POST /admin/consents/{id}/expires-at { "expiresAt": "2026-05-01" } id := pathSuffix(r.URL.Path, "/admin/consents/", "/expires-at") var body struct { ExpiresAt string `json:"expiresAt"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", err.Error()) return } t, err := time.Parse("2006-01-02", body.ExpiresAt) if err != nil { writeErr(w, http.StatusBadRequest, "FORMAT_ERROR", "invalid expiresAt") return } s.mu.Lock() defer s.mu.Unlock() if c, ok := s.consents[id]; ok { c.ExpiresAt = t w.WriteHeader(http.StatusNoContent) return } writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", "consent not found") } func (s *server) handleAdminListConsents(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() out := make([]map[string]any, 0, len(s.consents)) for _, c := range s.consents { out = append(out, map[string]any{ "id": c.ID, "status": c.Status, "bicFi": c.BicFi, "expiresAt": c.ExpiresAt.Format("2006-01-02"), }) } writeJSON(w, http.StatusOK, map[string]any{"consents": out}) } // --------------------------------------------------------------------- // Routing + startup. // --------------------------------------------------------------------- // pathSuffix extracts the segment between a prefix and suffix in a URL // path. Returns empty if either end doesn't match. func pathSuffix(path, prefix, suffix string) string { if !strings.HasPrefix(path, prefix) { return "" } rest := strings.TrimPrefix(path, prefix) if !strings.HasSuffix(rest, suffix) { return "" } return strings.TrimSuffix(rest, suffix) } func (s *server) healthz(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) _, _ = w.Write([]byte("ok")) } func (s *server) routeConsents(w http.ResponseWriter, r *http.Request) { // POST /psd2/consent/v1/consents // GET /psd2/consent/v1/consents/{id}/status // DELETE /psd2/consent/v1/consents/{id} p := r.URL.Path switch { case p == "/psd2/consent/v1/consents" && r.Method == http.MethodPost: s.handleCreateConsent(w, r) case strings.HasPrefix(p, "/psd2/consent/v1/consents/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodGet: s.handleConsentStatus(w, r) case strings.HasPrefix(p, "/psd2/consent/v1/consents/") && r.Method == http.MethodDelete: s.handleDeleteConsent(w, r) default: writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p)) } } func (s *server) routeAIS(w http.ResponseWriter, r *http.Request) { p := r.URL.Path switch { case p == "/psd2/aspspinformation/v1/aspsps" && r.Method == http.MethodGet: s.handleASPSPs(w, r) case p == "/psd2/accountinformation/v1/accounts" && r.Method == http.MethodGet: s.handleAccounts(w, r) case strings.HasPrefix(p, "/psd2/accountinformation/v1/accounts/") && strings.HasSuffix(p, "/balances") && r.Method == http.MethodGet: s.handleBalances(w, r) case strings.HasPrefix(p, "/psd2/accountinformation/v1/accounts/") && strings.HasSuffix(p, "/transactions") && r.Method == http.MethodGet: s.handleTransactions(w, r) case p == "/psd2/cardaccountinformation/v1/card-accounts" && r.Method == http.MethodGet: s.handleCardAccounts(w, r) default: writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p)) } } func (s *server) routePIS(w http.ResponseWriter, r *http.Request) { p := r.URL.Path switch { case p == "/psd2/paymentinitiation/v1/payments/sepa-credit-transfers" && r.Method == http.MethodPost: s.handleInitiatePayment(w, r) case strings.HasPrefix(p, "/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodGet: s.handlePaymentStatus(w, r) case p == "/psd2/v1/signing-baskets" && r.Method == http.MethodPost: s.handleCreateBasket(w, r) case strings.HasPrefix(p, "/psd2/v1/signing-baskets/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodGet: s.handleBasketStatus(w, r) default: writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p)) } } func (s *server) routeAdmin(w http.ResponseWriter, r *http.Request) { p := r.URL.Path switch { case p == "/admin/reset" && r.Method == http.MethodPost: s.handleAdminReset(w, r) case p == "/admin/consents" && r.Method == http.MethodGet: s.handleAdminListConsents(w, r) case p == "/admin/transactions" && r.Method == http.MethodPost: s.handleAdminSeedTransactions(w, r) case strings.HasPrefix(p, "/admin/payments/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodPost: s.handleAdminForcePaymentStatus(w, r) case strings.HasPrefix(p, "/admin/baskets/") && strings.HasSuffix(p, "/status") && r.Method == http.MethodPost: s.handleAdminForceBasketStatus(w, r) case strings.HasPrefix(p, "/admin/consents/") && strings.HasSuffix(p, "/expires-at") && r.Method == http.MethodPost: s.handleAdminConsentExpiry(w, r) default: writeErr(w, http.StatusNotFound, "RESOURCE_UNKNOWN", fmt.Sprintf("no route for %s %s", r.Method, p)) } } func (s *server) logMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { s.logger.Info("request", "method", r.Method, "path", r.URL.Path, "query", r.URL.RawQuery) next.ServeHTTP(w, r) }) } func main() { logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo})) s := newServer(logger) mux := http.NewServeMux() mux.Handle("/token", s.withCORS(s.handleToken)) mux.Handle("/authorize", s.withCORS(s.handleAuthorize)) mux.Handle("/healthz", s.withCORS(s.healthz)) mux.Handle("/psd2/consent/v1/consents", s.withCORS(s.routeConsents)) mux.Handle("/psd2/consent/v1/consents/", s.withCORS(s.routeConsents)) mux.Handle("/psd2/aspspinformation/v1/aspsps", s.withCORS(s.routeAIS)) mux.Handle("/psd2/accountinformation/v1/accounts", s.withCORS(s.routeAIS)) mux.Handle("/psd2/accountinformation/v1/accounts/", s.withCORS(s.routeAIS)) mux.Handle("/psd2/cardaccountinformation/v1/card-accounts", s.withCORS(s.routeAIS)) mux.Handle("/psd2/paymentinitiation/v1/payments/sepa-credit-transfers", s.withCORS(s.routePIS)) mux.Handle("/psd2/paymentinitiation/v1/payments/sepa-credit-transfers/", s.withCORS(s.routePIS)) mux.Handle("/psd2/v1/signing-baskets", s.withCORS(s.routePIS)) mux.Handle("/psd2/v1/signing-baskets/", s.withCORS(s.routePIS)) mux.Handle("/admin/reset", s.withCORS(s.routeAdmin)) mux.Handle("/admin/consents", s.withCORS(s.routeAdmin)) mux.Handle("/admin/consents/", s.withCORS(s.routeAdmin)) mux.Handle("/admin/transactions", s.withCORS(s.routeAdmin)) mux.Handle("/admin/payments/", s.withCORS(s.routeAdmin)) mux.Handle("/admin/baskets/", s.withCORS(s.routeAdmin)) addr := ":8080" if v := os.Getenv("PORT"); v != "" { addr = ":" + v } logger.Info("openpayments-mock starting", "addr", addr) if err := http.ListenAndServe(addr, s.logMiddleware(mux)); err != nil { logger.Error("server terminated", "err", err) os.Exit(1) } }