370ba70177
The initial .gitignore had 'service' as a bare pattern, which matches the cmd/service directory and silently excluded the entire mock implementation (~28KB of main source). Anchor the ignore patterns to the repo root so they only match top-level build artefacts, and force-add the real source now.
900 lines
28 KiB
Go
900 lines
28 KiB
Go
// 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)
|
|
}
|
|
}
|