Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go/backend/admin/reports.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()))
}
Expand Down
22 changes: 17 additions & 5 deletions go/backend/reports/pdf.go
Original file line number Diff line number Diff line change
Expand Up @@ -69,12 +69,16 @@ 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},
{"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})
Expand All @@ -93,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.
Expand Down Expand Up @@ -207,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 != "" {
Expand Down
63 changes: 35 additions & 28 deletions go/backend/reports/provider_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<name>_user_id" column with the provider's user ID.
externalUserID func(ctx context.Context, b Backends, walletID string) (string, error)
}
Expand All @@ -85,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
}

Expand All @@ -96,7 +99,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.
Expand Down Expand Up @@ -179,8 +183,11 @@ 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",
"email", "first_name", "last_name", "user_id", "signup_country",
"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")
Expand Down Expand Up @@ -210,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
Expand All @@ -219,7 +226,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)
}
Expand Down Expand Up @@ -267,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(),
}
}

Expand Down Expand Up @@ -323,22 +333,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)
Expand All @@ -364,11 +376,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"`
Expand Down
34 changes: 20 additions & 14 deletions go/backend/reports/provider_users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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)
}
Expand Down Expand Up @@ -68,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",
Expand All @@ -81,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)
Expand Down Expand Up @@ -116,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"},
},
Expand Down Expand Up @@ -217,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)
}
}
}
}
3 changes: 2 additions & 1 deletion go/backend/reports/xago_users.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,11 @@ 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,
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)
Expand Down
2 changes: 1 addition & 1 deletion typescript/botanist/app/routes/_index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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' }
]

Expand Down
4 changes: 2 additions & 2 deletions typescript/botanist/app/routes/reports.xago-users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
}
Loading