From b8d9818f5b82a4d98e18544e5dc08d52ee8b1dcd Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 10 Nov 2026 10:09:41 +0200 Subject: [PATCH 1/2] fix: remove persona inquiry form gatehub --- go/backend/reports/pdf.go | 8 +++- go/backend/reports/provider_users.go | 52 +++++++++++++---------- go/backend/reports/provider_users_test.go | 5 +++ go/backend/reports/xago_users.go | 1 + 4 files changed, 41 insertions(+), 25 deletions(-) diff --git a/go/backend/reports/pdf.go b/go/backend/reports/pdf.go index 533a3a331..513411fec 100644 --- a/go/backend/reports/pdf.go +++ b/go/backend/reports/pdf.go @@ -73,8 +73,12 @@ func (p providerReport) fields(u reportUser, w reportWallet) []pdfField { {"wallet_id", "Wallet ID", w.WalletID}, {"wallet_country", "Wallet country", w.Country}, {"local_kyc", "Local KYC *", w.LocalKYC}, - {"persona_inquiry_id", "Persona inquiry ID", w.PersonaInquiryID}, - {"persona_inquiry_state", "Persona inquiry state", w.PersonaInquiryState}, + } + if p.persona { + f = append(f, + pdfField{"persona_inquiry_id", "Persona inquiry ID", w.PersonaInquiryID}, + pdfField{"persona_inquiry_state", "Persona inquiry state", w.PersonaInquiryState}, + ) } if p.externalUserID != nil { f = append(f, pdfField{p.name + "_user_id", p.displayName + " user ID", w.ExternalUserID}) diff --git a/go/backend/reports/provider_users.go b/go/backend/reports/provider_users.go index c2cb23f6e..f4b988b54 100644 --- a/go/backend/reports/provider_users.go +++ b/go/backend/reports/provider_users.go @@ -70,6 +70,8 @@ type providerReport struct { selectUsers func(ctx context.Context, b Backends, identities []user.User) (alwaysInclude []string, wallets []walletRow, err error) // balance returns the total and available balance of a balance linked account. balance func(ctx context.Context, b Backends, linkedAccountID string) (total, available currency.Amount, err error) + // persona adds the Persona inquiry ID and state columns. + persona bool // externalUserID, if set, adds a "_user_id" column with the provider's user ID. externalUserID func(ctx context.Context, b Backends, walletID string) (string, error) } @@ -96,7 +98,8 @@ type reportWallet struct { // LocalKYC is the app's own KYC status for the wallet (wallet_kyc_status), e.g. "Approved". LocalKYC string // PersonaInquiryID is the wallet's latest approved Persona inquiry, or its most recent - // inquiry if none is approved; empty if the wallet never started KYC. + // inquiry if none is approved; empty if the wallet never started KYC. Only set when + // the report has the Persona columns. PersonaInquiryID string PersonaInquiryState string // ExternalUserID is the provider's ID for the user; only set when the report has that column. @@ -180,7 +183,10 @@ func (p providerReport) collect(ctx context.Context, b Backends) ([]reportUser, func (p providerReport) header() []string { h := []string{ "email", "first_name", "last_name", "user_id", "kratos_country", - "wallet_id", "wallet_country", "local_kyc", "persona_inquiry_id", "persona_inquiry_state", + "wallet_id", "wallet_country", "local_kyc", + } + if p.persona { + h = append(h, "persona_inquiry_id", "persona_inquiry_state") } if p.externalUserID != nil { h = append(h, p.name+"_user_id") @@ -219,7 +225,10 @@ func (p providerReport) csv(users []reportUser) (string, error) { } for _, w := range u.Wallets { - walletCols := []string{w.WalletID, w.Country, w.LocalKYC, w.PersonaInquiryID, w.PersonaInquiryState} + walletCols := []string{w.WalletID, w.Country, w.LocalKYC} + if p.persona { + walletCols = append(walletCols, w.PersonaInquiryID, w.PersonaInquiryState) + } if p.externalUserID != nil { walletCols = append(walletCols, w.ExternalUserID) } @@ -323,22 +332,24 @@ func (p providerReport) fillWallet(ctx context.Context, b Backends, w *reportWal } w.LocalKYC = kycStatus.String() - var inquiry struct { - ExternalID string `db:"external_id"` - State string `db:"state"` - } - err = b.DB().GetContext(ctx, &inquiry, ` - SELECT external_id, state - FROM kyc_persona_inquiries - WHERE wallet_id = $1 - ORDER BY (state = $2) DESC, updated_at DESC - LIMIT 1`, - w.WalletID, persona.InquiryApproved) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - return fmt.Errorf("getting persona inquiry: %w", err) + if p.persona { + var inquiry struct { + ExternalID string `db:"external_id"` + State string `db:"state"` + } + err := b.DB().GetContext(ctx, &inquiry, ` + SELECT external_id, state + FROM kyc_persona_inquiries + WHERE wallet_id = $1 + ORDER BY (state = $2) DESC, updated_at DESC + LIMIT 1`, + w.WalletID, persona.InquiryApproved) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("getting persona inquiry: %w", err) + } + w.PersonaInquiryID = inquiry.ExternalID + w.PersonaInquiryState = inquiry.State } - w.PersonaInquiryID = inquiry.ExternalID - w.PersonaInquiryState = inquiry.State if p.externalUserID != nil { w.ExternalUserID, err = p.externalUserID(ctx, b, w.WalletID) @@ -364,11 +375,6 @@ func (p providerReport) fillWallet(ctx context.Context, b Backends, w *reportWal return nil } -// listTransactions returns the wallet's transactions in the given currency, newest first. -// A transaction is stored in its own wallet's currency (a cross-currency payment is -// saved once per side), so this picks the transactions of the provider that holds it. -// transactions.provider isn't used because Rafiki labels payments "gatehub" and wallet -// to wallet payments "payments_engine" whatever account paid. func listTransactions(ctx context.Context, dbc *sqlx.DB, walletID string, cc currency.Currency) ([]reportTransaction, error) { var txs []struct { ID string `db:"id"` diff --git a/go/backend/reports/provider_users_test.go b/go/backend/reports/provider_users_test.go index 1d6976893..549af693e 100644 --- a/go/backend/reports/provider_users_test.go +++ b/go/backend/reports/provider_users_test.go @@ -31,6 +31,11 @@ func TestGatehubHeader(t *testing.T) { t.Errorf("gatehub header is missing %q: %v", c, h) } } + for _, c := range []string{"persona_inquiry_id", "persona_inquiry_state"} { + if _, ok := idx[c]; ok { + t.Errorf("gatehub header should not have %q: %v", c, h) + } + } if idx["gatehub_user_id"] > idx["gatehub_linked_account_id"] { t.Errorf("gatehub_user_id should come before gatehub_linked_account_id: %v", h) } diff --git a/go/backend/reports/xago_users.go b/go/backend/reports/xago_users.go index 4f746b6a5..e25151687 100644 --- a/go/backend/reports/xago_users.go +++ b/go/backend/reports/xago_users.go @@ -19,6 +19,7 @@ var xagoUsersReport = providerReport{ linkedAccountProvider: xago.ProviderName, linkedAccountType: xago.AccTypeBalance, currency: currency.ZAR, + persona: true, selectUsers: selectXagoUsers, balance: func(ctx context.Context, b Backends, linkedAccountID string) (currency.Amount, currency.Amount, error) { bal, err := b.Xago().GetBalance(ctx, linkedAccountID) From c78bbde4f796cc0228f39f7d04a168caaa901f64 Mon Sep 17 00:00:00 2001 From: Raul Date: Tue, 10 Nov 2026 12:20:43 +0200 Subject: [PATCH 2/2] fix: add clarifying labels --- go/backend/admin/reports.go | 2 +- go/backend/reports/pdf.go | 14 +++++++-- go/backend/reports/provider_users.go | 11 +++---- go/backend/reports/provider_users_test.go | 29 ++++++++++--------- go/backend/reports/xago_users.go | 2 +- typescript/botanist/app/routes/_index.tsx | 2 +- .../botanist/app/routes/reports.xago-users.ts | 4 +-- 7 files changed, 37 insertions(+), 27 deletions(-) diff --git a/go/backend/admin/reports.go b/go/backend/admin/reports.go index 3f152d90a..8aeabaf8e 100644 --- a/go/backend/admin/reports.go +++ b/go/backend/admin/reports.go @@ -8,7 +8,7 @@ import ( adminv1 "github.com/interledger/interledger-app/go/proto/backend/admin/v1" ) -// GetXagoUsersReport returns the Xago users report as a CSV or PDF download. +// GetXagoUsersReport returns the Xago and South African users report as a CSV or PDF download. func (s *AdminRpcService) GetXagoUsersReport(ctx context.Context, req *adminv1.UsersReportRequest) (*adminv1.ReportFile, error) { return reportFile(reports.XagoUsers(ctx, s.b, reportFormat(req.GetFormat()), time.Now())) } diff --git a/go/backend/reports/pdf.go b/go/backend/reports/pdf.go index 513411fec..71bd5c1e9 100644 --- a/go/backend/reports/pdf.go +++ b/go/backend/reports/pdf.go @@ -69,9 +69,9 @@ func (p providerReport) fields(u reportUser, w reportWallet) []pdfField { {"first_name", "First name", u.FirstName}, {"last_name", "Last name", u.LastName}, {"user_id", "User ID", u.UserID}, - {"kratos_country", "Kratos country", u.KratosCountry}, + {"signup_country", "Signup country", u.SignupCountry}, {"wallet_id", "Wallet ID", w.WalletID}, - {"wallet_country", "Wallet country", w.Country}, + {"wallet_country", "Wallet country **", w.Country}, {"local_kyc", "Local KYC *", w.LocalKYC}, } if p.persona { @@ -97,6 +97,13 @@ func (p providerReport) kycDisclaimer() string { p.displayName + "; check it there before relying on it." } +// walletCountryDisclaimer explains that a wallet's country picks the provider, and that +// the provider decides whether the user is eligible. +func (p providerReport) walletCountryDisclaimer() string { + return "** Wallet country can be changed to route a user to a specific provider. " + + p.displayName + " decides whether the user is eligible." +} + // pdf renders the report with one section per wallet: the user's and wallet's details // followed by the wallet's transaction history. Users without wallets get a section // with the wallet fields left empty. @@ -211,10 +218,11 @@ func (p providerReport) pdfSection(doc *fpdf.Fpdf, u reportUser, w reportWallet, doc.Ln(-1) } - // Footnote for the "Local KYC *" label. + // Footnotes for the "Local KYC *" and "Wallet country **" labels. doc.SetFont(pdfFontFamily, "", 7) doc.SetTextColor(110, 110, 110) doc.CellFormat(0, 4, p.kycDisclaimer(), "", 1, "L", false, 0, "") + doc.CellFormat(0, 4, p.walletCountryDisclaimer(), "", 1, "L", false, 0, "") doc.SetTextColor(0, 0, 0) if w.Error != "" { diff --git a/go/backend/reports/provider_users.go b/go/backend/reports/provider_users.go index f4b988b54..1cc8e2eaf 100644 --- a/go/backend/reports/provider_users.go +++ b/go/backend/reports/provider_users.go @@ -87,8 +87,9 @@ type reportUser struct { Email string FirstName string LastName string - // KratosCountry is the countryCode trait; empty if the identity is gone from Kratos. - KratosCountry string + // SignupCountry is the country the user chose at signup (the Kratos countryCode trait); + // empty if the identity is gone from Kratos. + SignupCountry string Wallets []reportWallet } @@ -182,7 +183,7 @@ func (p providerReport) collect(ctx context.Context, b Backends) ([]reportUser, func (p providerReport) header() []string { h := []string{ - "email", "first_name", "last_name", "user_id", "kratos_country", + "email", "first_name", "last_name", "user_id", "signup_country", "wallet_id", "wallet_country", "local_kyc", } if p.persona { @@ -216,7 +217,7 @@ func (p providerReport) csv(users []reportUser) (string, error) { } for _, u := range users { - userCols := []string{u.Email, u.FirstName, u.LastName, u.UserID, u.KratosCountry} + userCols := []string{u.Email, u.FirstName, u.LastName, u.UserID, u.SignupCountry} if len(u.Wallets) == 0 { if err := cw.Write(append(userCols, make([]string, len(header)-len(userCols))...)); err != nil { return "", err @@ -276,7 +277,7 @@ func (p providerReport) listUsers(ctx context.Context, b Backends) ([]reportUser Email: strings.TrimSpace(id.Email), FirstName: strings.TrimSpace(id.FirstName), LastName: strings.TrimSpace(id.LastName), - KratosCountry: id.Country.String(), + SignupCountry: id.Country.String(), } } diff --git a/go/backend/reports/provider_users_test.go b/go/backend/reports/provider_users_test.go index 549af693e..fe6b4befb 100644 --- a/go/backend/reports/provider_users_test.go +++ b/go/backend/reports/provider_users_test.go @@ -10,7 +10,7 @@ import ( func TestXagoHeaderUnchanged(t *testing.T) { want := []string{ - "email", "first_name", "last_name", "user_id", "kratos_country", + "email", "first_name", "last_name", "user_id", "signup_country", "wallet_id", "wallet_country", "local_kyc", "persona_inquiry_id", "persona_inquiry_state", "xago_linked_account_id", "balance", "available_balance", "balance_currency", "transaction_id", "created_at", "updated_at", "type", "state", "provider", "amount", "fee", "currency", "title", "error", @@ -73,7 +73,7 @@ func TestXagoUsersCSV(t *testing.T) { created := time.Date(2026, 10, 1, 12, 0, 0, 0, time.FixedZone("SAST", 2*60*60)) users := []reportUser{ { - UserID: "u1", Email: "a@example.test", FirstName: "Ann", LastName: "Lee", KratosCountry: "ZA", + UserID: "u1", Email: "a@example.test", FirstName: "Ann", LastName: "Lee", SignupCountry: "ZA", Wallets: []reportWallet{ { WalletID: "w1", Country: "ZA", LocalKYC: "Approved", PersonaInquiryID: "inq_1", PersonaInquiryState: "approved", @@ -86,7 +86,7 @@ func TestXagoUsersCSV(t *testing.T) { {WalletID: "w2", Country: "ZA", Error: "boom"}, }, }, - {UserID: "u2", Email: "b@example.test", KratosCountry: "ZA"}, + {UserID: "u2", Email: "b@example.test", SignupCountry: "ZA"}, } rows, col := readReport(t, xagoUsersReport, users) @@ -121,7 +121,7 @@ func TestXagoUsersCSV(t *testing.T) { func TestGatehubUsersCSV(t *testing.T) { users := []reportUser{ { - UserID: "u1", Email: "a@example.test", KratosCountry: "DE", + UserID: "u1", Email: "a@example.test", SignupCountry: "DE", Wallets: []reportWallet{ {WalletID: "w1", Country: "DE", LocalKYC: "Approved", ExternalUserID: "gh-1", LinkedAccountID: "la1", Balance: "5.00", AvailableBalance: "5.00", BalanceCurrency: "EUR"}, }, @@ -222,17 +222,18 @@ func TestFit(t *testing.T) { } } -func TestKYCDisclaimer(t *testing.T) { - // The disclaimer names the report's provider and fits on one line of the page. +func TestDisclaimers(t *testing.T) { + // Each disclaimer names the report's provider and fits on one line of the page. for _, p := range []providerReport{xagoUsersReport, gatehubUsersReport} { - d := p.kycDisclaimer() - if !strings.Contains(d, p.displayName) { - t.Errorf("%s: disclaimer %q does not name the provider", p.name, d) - } - doc := p.newPDF(time.Now()) - doc.SetFont(pdfFontFamily, "", 7) - if w := doc.GetStringWidth(d); w > 277 { - t.Errorf("%s: disclaimer is %.0fmm wide, wider than the page", p.name, w) + for _, d := range []string{p.kycDisclaimer(), p.walletCountryDisclaimer()} { + if !strings.Contains(d, p.displayName) { + t.Errorf("%s: disclaimer %q does not name the provider", p.name, d) + } + doc := p.newPDF(time.Now()) + doc.SetFont(pdfFontFamily, "", 7) + if w := doc.GetStringWidth(d); w > 277 { + t.Errorf("%s: disclaimer is %.0fmm wide, wider than the page", p.name, w) + } } } } diff --git a/go/backend/reports/xago_users.go b/go/backend/reports/xago_users.go index e25151687..09b21c782 100644 --- a/go/backend/reports/xago_users.go +++ b/go/backend/reports/xago_users.go @@ -15,7 +15,7 @@ import ( var xagoUsersReport = providerReport{ name: "xago", displayName: "Xago", - title: "South Africa users report", + title: "Xago and South African users report", linkedAccountProvider: xago.ProviderName, linkedAccountType: xago.AccTypeBalance, currency: currency.ZAR, diff --git a/typescript/botanist/app/routes/_index.tsx b/typescript/botanist/app/routes/_index.tsx index 185183d15..44bba0f46 100644 --- a/typescript/botanist/app/routes/_index.tsx +++ b/typescript/botanist/app/routes/_index.tsx @@ -52,7 +52,7 @@ export async function loader({ request }: LoaderFunctionArgs) { } const REPORTS = [ - { label: 'South Africa users report', path: '/reports/xago-users' }, + { label: 'Xago users report', path: '/reports/xago-users' }, { label: 'GateHub users report', path: '/reports/gatehub-users' } ] diff --git a/typescript/botanist/app/routes/reports.xago-users.ts b/typescript/botanist/app/routes/reports.xago-users.ts index b394ca154..72ef681b0 100644 --- a/typescript/botanist/app/routes/reports.xago-users.ts +++ b/typescript/botanist/app/routes/reports.xago-users.ts @@ -2,11 +2,11 @@ import type { LoaderFunctionArgs } from 'react-router' import { grpcClient } from '~/lib/proto.server' import { reportResponse } from '~/lib/reports.server' -// Resource route: downloads the South Africa (Xago) users report as CSV, or PDF with ?format=pdf. +// Resource route: downloads the Xago users report as CSV, or PDF with ?format=pdf. export async function loader({ request }: LoaderFunctionArgs) { return reportResponse( request, grpcClient.getXagoUsersReport.bind(grpcClient), - 'South Africa users report' + 'Xago users report' ) }