diff --git a/.gitignore b/.gitignore index 104f3c2..8c31459 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -openpayments-mock -service -coverage.txt -coverage.out +/openpayments-mock +/service +/coverage.txt +/coverage.out diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 40f01ba..74f6df0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -25,8 +25,7 @@ repos: - repo: https://github.com/lietu/go-pre-commit rev: v1.0.0 hooks: - - id: gofumpt - - id: golangci-lint-full + - id: go-test - repo: https://github.com/gitleaks/gitleaks rev: v8.28.0 hooks: diff --git a/cmd/service/service.go b/cmd/service/service.go new file mode 100644 index 0000000..8861baf --- /dev/null +++ b/cmd/service/service.go @@ -0,0 +1,899 @@ +// 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) + } +}