Platform admin: tenant payments history

2026-09-07 · branch feat/platform-tenant-transactions · SkillPixel LMS platform-admin

The platform-admin app gains a Payments tab on each tenant's detail page. It lets a platform operator audit a single tenant's payment history: read a monthly summary (net revenue, failure rate, why payments fail, what sells), then filter and page through the raw transactions and open any one for full detail. The goal is operational visibility — spotting failing payments and seeing how each tenant actually sells — without touching tenant-facing surfaces. Every endpoint is read-only and tenant-scoped, and the data below is seeded demo data on the local Docker stack.

What was built

Three read-only backend endpoints under /api/v1/platform (gated by the platform-admin guard), surfaced through three Next.js proxy routes and one UI tab.

Seeded data

Two demo tenants were empty, so the seed created instructor + student users, courses, and memberships, then inserted transactions spread over the last 60 days (weighted to Aug – Sep 2026), each with 1–2 line items. All inserts ran in one transaction.

StatusTenant ATenant B
COMPLETED253
FAILED91
PENDING41
PROCESSING20
CANCELLED20
REFUNDED20
PARTIALLY_REFUNDED10
Total transactions455
Line items617

Mix: mostly SEPAY with some ALEPAY; BANK_TRANSFER / DIGITAL_WALLET; a couple of USD amounts; several rows carry a voucher + discount; failed rows use varied reasons (some null). Users, courses and memberships were created for both tenants (Tenant A: 1 instructor + 5 students + 4 courses; Tenant B: 1 instructor + 2 students + 2 courses).

Walkthrough

Signed in as the platform admin (skillpixeldeveloper@gmail.com). Click any image to enlarge. device_scale_factor = 2.

1. Payments tab — default view

Desktop 1440×900 — full page. September 2026 summary on top, then the filter bar and the transaction table (45 rows, page 1 of 3).
Desktop 1440×900 — full page. September 2026 summary on top, then the filter bar and the transaction table (45 rows, page 1 of 3).

2. Summary section close-up

Net revenue per currency (net of discounts and refunds), failure rate, a status-count strip covering all seven statuses, top failure reasons, and best-selling courses this month.
Net revenue per currency (net of discounts and refunds), failure rate, a status-count strip covering all seven statuses, top failure reasons, and best-selling courses this month.

3. Filter: status = FAILED

The status select narrows the table to FAILED rows only. The Failure column shows the reason (Bank timeout, Card declined by issuer, …); rows with a null reason show blank.
The status select narrows the table to FAILED rows only. The Failure column shows the reason (Bank timeout, Card declined by issuer, …); rows with a null reason show blank.

4. Search by user email

Typing hoa.pham@tenanta.demo in the search box (debounced ~300 ms) filters to that buyer's transactions. Search also matches nanoid, provider transaction id, order code, and name.
Typing hoa.pham@tenanta.demo in the search box (debounced ~300 ms) filters to that buyer's transactions. Search also matches nanoid, provider transaction id, order code, and name.

5. Date range filter

From 2026-08-01 To 2026-08-31 restricts the table to transactions created in August. The count and page total update accordingly.
From 2026-08-01 To 2026-08-31 restricts the table to transactions created in August. The count and page total update accordingly.

6. Pagination — page 2

Next moves to page 2 of 3 (page size 20). The footer shows the running total and the current page; Prev/Next disable at the ends.
Next moves to page 2 of 3 (page size 20). The footer shows the running total and the current page; Prev/Next disable at the ends.

7. Detail sheet — COMPLETED

Clicking a row opens a side sheet grouped into Payment / Provider / Customer / Items, with provider metadata as formatted JSON.
Clicking a row opens a side sheet grouped into Payment / Provider / Customer / Items, with provider metadata as formatted JSON.

8. Detail sheet — FAILED and REFUNDED

FAILED transaction — the failure reason (Bank timeout) is shown in the Payment group; provider ids and metadata are still present.
FAILED transaction — the failure reason (Bank timeout) is shown in the Payment group; provider ids and metadata are still present.
REFUNDED transaction — Refunded amount and Refund reason appear in the Payment group.
REFUNDED transaction — Refunded amount and Refund reason appear in the Payment group.

9. Month stepper — August 2026

The month arrows drive only the summary. Stepping back to August 2026 recomputes revenue, failure rate, status counts, and top courses for that month.
The month arrows drive only the summary. Stepping back to August 2026 recomputes revenue, failure rate, status counts, and top courses for that month.

10. Tenant B — isolation

Tenant B's Payments tab shows only its own 5 transactions and its own two courses (page 1 of 1). No Tenant A data leaks across.
Tenant B's Payments tab shows only its own 5 transactions and its own two courses (page 1 of 1). No Tenant A data leaks across.

11. Overview tab — context

The tenant Overview tab, for context on where the new Payments tab sits in the tenant detail page.
The tenant Overview tab, for context on where the new Payments tab sits in the tenant detail page.

API samples

Raw JSON captured by calling the backend directly with the minted id token.

GET /transactions/summary (2026-09)
{
  "month": "2026-09",
  "status_counts": {
    "PENDING": 2,
    "PROCESSING": 1,
    "COMPLETED": 13,
    "FAILED": 5,
    "CANCELLED": 2,
    "REFUNDED": 1,
    "PARTIALLY_REFUNDED": 1
  },
  "revenue_by_currency": [
    {
      "currency": "VND",
      "net_amount": 7340700,
      "completed_count": 15
    }
  ],
  "failure_rate": 0.2777777777777778,
  "top_failure_reasons": [
    {
      "reason": "Payment window expired",
      "count": 2
    },
    {
      "reason": "Bank timeout",
      "count": 1
    },
    {
      "reason": "Card declined by issuer",
      "count": 1
    },
    {
      "reason": "unknown",
      "count": 1
    }
  ],
  "top_courses": [
    {
      "course_id": "ux-design-essentials",
      "course_title": "UX Design Essentials",
      "completed_count": 6,
      "net_amount": 3231500,
      "currency": "VND"
    },
    {
      "course_id": "web-dev-bootcamp",
      "course_title": "Web Development Bootcamp",
      "completed_count": 5,
      "net_amount": 2132500,
      "currency": "VND"
    },
    {
      "course_id": "ml-foundations",
      "course_title": "Machine Learning Foundations",
      "completed_count": 4,
      "net_amount": 1143000,
      "currency": "VND"
    },
    {
      "course_id": "data-science-python",
      "course_title": "Data Science with Python",
      "completed_count": 3,
      "net_amount": 744000,
      "currency": "VND"
    }
  ]
}
GET /transactions/summary (2026-08)
{
  "month": "2026-08",
  "status_counts": {
    "PENDING": 2,
    "PROCESSING": 1,
    "COMPLETED": 10,
    "FAILED": 4,
    "CANCELLED": 0,
    "REFUNDED": 0,
    "PARTIALLY_REFUNDED": 0
  },
  "revenue_by_currency": [
    {
      "currency": "VND",
      "net_amount": 5213300,
      "completed_count": 10
    }
  ],
  "failure_rate": 0.2857142857142857,
  "top_failure_reasons": [
    {
      "reason": "Insufficient funds",
      "count": 2
    },
    {
      "reason": "Signature mismatch",
      "count": 1
    },
    {
      "reason": "unknown",
      "count": 1
    }
  ],
  "top_courses": [
    {
      "course_id": "ml-foundations",
      "course_title": "Machine Learning Foundations",
      "completed_count": 3,
      "net_amount": 1738500,
      "currency": "VND"
    },
    {
      "course_id": "web-dev-bootcamp",
      "course_title": "Web Development Bootcamp",
      "completed_count": 4,
      "net_amount": 1684000,
      "currency": "VND"
    },
    {
      "course_id": "data-science-python",
      "course_title": "Data Science with Python",
      "completed_count": 4,
      "net_amount": 1292500,
      "currency": "VND"
    },
    {
      "course_id": "ux-design-essentials",
      "course_title": "UX Design Essentials",
      "completed_count": 3,
      "net_amount": 648000,
      "currency": "VND"
    }
  ]
}
GET /transactions?status=FAILED&size=3
{
  "items": [
    {
      "transaction_id": "McNfAQLKHu7qnQTupqziQ",
      "user": {
        "name": "An Le",
        "email": "an.le@tenanta.demo"
      },
      "amount": 990000,
      "discount_amount": 0,
      "final_amount": 990000,
      "refund_amount": 0,
      "currency": "VND",
      "status": "FAILED",
      "provider": "SEPAY",
      "payment_method": "BANK_TRANSFER",
      "failure_reason": "Bank timeout",
      "course_titles": [
        "Machine Learning Foundations",
        "Data Science with Python"
      ],
      "created_at": "2026-09-07T08:00:00Z",
      "completed_at": null,
      "expires_at": null
    },
    {
      "transaction_id": "suhaFVBliyIToGJ1QZwez",
      "user": {
        "name": "Minh Tran",
        "email": "minh.tran@tenanta.demo"
      },
      "amount": 499000,
      "discount_amount": 99800,
      "final_amount": 399200,
      "refund_amount": 0,
      "currency": "VND",
      "status": "FAILED",
      "provider": "SEPAY",
      "payment_method": "DIGITAL_WALLET",
      "failure_reason": null,
      "course_titles": [
        "Data Science with Python"
      ],
      "created_at": "2026-09-04T08:55:00Z",
      "completed_at": null,
      "expires_at": null
    },
    {
      "transaction_id": "0ULru2p17fr4CpWDKNQyv",
      "user": {
        "name": "Minh Tran",
        "email": "minh.tran@tenanta.demo"
      },
      "amount": 299000,
      "discount_amount": 0,
      "final_amount": 299000,
      "refund_amount": 0,
      "currency": "VND",
      "status": "FAILED",
      "provider": "SEPAY",
      "payment_method": "DIGITAL_WALLET",
      "failure_reason": "Card declined by issuer",
      "course_titles": [
        "Data Science with Python",
        "Machine Learning Foundations"
      ],
      "created_at": "2026-09-03T11:31:00Z",
      "completed_at": null,
      "expires_at": null
    }
  ],
  "total": 9,
  "page": 1,
  "size": 3,
  "total_pages": 3,
  "has_next": true,
  "has_previous": false
}
GET /transactions/{nanoid} (COMPLETED detail)
{
  "transaction_id": "mTecQoXsf2o3gyrDO1xkx",
  "user": {
    "name": "An Le",
    "email": "an.le@tenanta.demo"
  },
  "amount": 299000,
  "discount_amount": 0,
  "final_amount": 299000,
  "refund_amount": 0,
  "currency": "VND",
  "status": "COMPLETED",
  "provider": "SEPAY",
  "payment_method": "BANK_TRANSFER",
  "failure_reason": null,
  "course_titles": [
    "Web Development Bootcamp",
    "Data Science with Python"
  ],
  "created_at": "2026-09-03T08:38:00Z",
  "completed_at": "2026-09-03T09:00:00Z",
  "expires_at": null,
  "items": [
    {
      "course_id": "web-dev-bootcamp",
      "course_title": "Web Development Bootcamp",
      "amount": 149500,
      "currency": "VND"
    },
    {
      "course_id": "data-science-python",
      "course_title": "Data Science with Python",
      "amount": 149500,
      "currency": "VND"
    }
  ],
  "provider_transaction_id": "SEPAY-TXN-00001-Dyr7OS",
  "provider_order_code": "ORD0000001",
  "provider_payment_url": null,
  "provider_metadata": {
    "bank": "VPBank",
    "ref": "FT2609034598"
  },
  "voucher_code": null,
  "voucher_percent": null,
  "voucher_fixed_amount": null,
  "refund_reason": null,
  "description": "Purchase: Web Development Bootcamp",
  "full_name": "Le Van C",
  "phone_number": "0903056413",
  "address": null,
  "school_name": "RMIT Vietnam",
  "updated_at": "2026-09-03T08:38:00Z"
}

Checks

Backend (apps/backend)

platform-admin (apps/platform-admin)

Browser walk (this run)

Open items / observations