37

PayGoHub - Solar Pay-As-You-Go Platform

ASP.NET Core 10.0 MVC platform for solar home system management — payments, loans, installations, device monitoring — deployed to GCP Cloud Run (africa-south1) with Google OAuth and EF Core on Cloud SQL PostgreSQL.

Preview

PayGoHub manages the full customer lifecycle for solar Pay-As-You-Go companies: onboarding, M-Pesa payments, loan tracking, installation scheduling, and device health — from a single ASP.NET Core MVC dashboard targeting African markets (Kenya, Tanzania, Uganda).

Live: paygohub-904401126919.africa-south1.run.app API Docs: /api-docs Source: github.com/ericgitangu/PayGoHub

Architecture

Clean Architecture — four projects with strict dependency inversion:

PayGoHub/
├── src/
│   ├── PayGoHub.Domain/           # Entities, Enums
│   ├── PayGoHub.Application/      # Service interfaces, DTOs
│   ├── PayGoHub.Infrastructure/   # EF Core, entity configs, service impls
│   └── PayGoHub.Web/              # MVC controllers, Razor views, middleware
├── tests/
│   ├── PayGoHub.Tests/            # xUnit unit + integration
│   └── PayGoHub.E2E/              # Playwright E2E
├── cloudbuild.yaml
└── Dockerfile                     # Multi-stage: SDK → runtime

Key Features

  • Dashboard — Real-time KPIs: revenue, customers, active loans, installations; Chart.js visualisations
  • Customer Management — Full CRUD with region/district organisation and M-Pesa integration
  • Payment Processing — M-Pesa, MTN MoMo, Bank, Cash tracking
  • Loan Management — Lifecycle from application through payoff with balance calculations
  • Installations — Technician scheduling, SHS system assignment, completion tracking
  • Device Monitoring — Serial numbers, battery health, sync status across 80W–200W systems
  • Activity Feed — Entity-level audit log with icon-tagged entries
  • Google OAuth — One-click sign-in; cookie-only fallback when credentials absent

Technology Stack

Backend

  • ASP.NET Core 10.0 MVC (.NET 10 preview)
  • EF Core 10.0 + Npgsql.EntityFrameworkCore.PostgreSQL 10.0
  • PostgreSQL 16 (GCP Cloud SQL, africa-south1)
  • ASP.NET Core Data Protection (EF Core key-ring persistence)
  • Google OAuth 2.0 (conditional registration — app boots without credentials)
  • Swagger / OpenAPI via Swashbuckle

Frontend

  • Razor Views · Bootstrap 5 · Chart.js · Bootstrap Icons

DevOps

  • GCP Cloud Run (africa-south1, scale-to-zero, free tier)
  • Artifact Registry — Docker image store
  • Cloud Build — CI/CD container pipeline
  • Secret Manager — OAuth credentials at runtime
  • Cloud SQL — Shared PostgreSQL, unix-socket connection
  • GitHub Actions — build, test, security scan, E2E

Deployment

# Build via Cloud Build
gcloud builds submit \
  --tag africa-south1-docker.pkg.dev/pawacloud-assessment/pawacloud/paygohub:latest \
  --region=africa-south1 --project=pawacloud-assessment
 
# Deploy to Cloud Run
gcloud run deploy paygohub \
  --image=africa-south1-docker.pkg.dev/pawacloud-assessment/pawacloud/paygohub:latest \
  --region=africa-south1 --project=pawacloud-assessment
 
# Logs
gcloud logging read \
  'resource.type="cloud_run_revision" resource.labels.service_name="paygohub"' \
  --project=pawacloud-assessment --limit=50

Engineering Challenges

1 — Migration idempotency on Cloud Run

Problem: EF Core's MigrateAsync() runs migrations in a single call. A failure in any migration's Up() halts the chain — subsequent migrations never apply. Cloud Run containers start fresh on every revision, so each deploy re-runs MigrateAsync() against migrations that may be partially applied.

Root cause: An early corrective migration (FixSnakeCaseSchema) tried to drop/recreate tables and rename columns without guarding against already-applied state. A downstream migration (AddDataProtectionKeys) conflicted with it — both tried the same renames, and whichever ran first left the other in an invalid starting state.

Fix: Replaced all structural operations in corrective migrations with guarded raw SQL:

DO $$
BEGIN
    IF EXISTS (
        SELECT 1 FROM information_schema.columns
        WHERE table_name = 'customers' AND column_name = 'AccountNumber'
    ) THEN
        ALTER TABLE customers RENAME COLUMN "AccountNumber" TO account_number;
    END IF;
END $$;
 
CREATE TABLE IF NOT EXISTS "DataProtectionKeys" (...);

Rule: Every corrective migration must be a no-op when re-run on a DB where the change is already applied.

2 — Npgsql column name mismatch (PascalCase vs snake_case)

Problem: Raw SQL migrations from an earlier phase created columns with quoted PascalCase names ("AccountNumber"). Entity configurations used explicit HasColumnName("account_number") — Npgsql's default does not auto-convert. Result: every query touching customers failed with column c.account_number does not exist.

Fix: Added a targeted migration with a guarded RENAME COLUMN. Also audited all entity configurations in Data/Configurations/ to confirm HasColumnName declarations match the physical schema.

Rule: If you mix raw-SQL migrations with EF-scaffolded migrations, keep a single source of truth for column names. Prefer explicit HasColumnName in config, verify it matches what the DB actually has.

3 — OAuth correlation failures across container restarts

Problem: ASP.NET Core Data Protection generates a key ring in-process at startup. On Cloud Run, each revision or scale-out spawns a new container with a fresh ring. The OAuth challenge writes a state cookie encrypted by ring A; the callback hits container B (ring B) → Correlation failed.

Fix:

  1. PayGoHubDbContext implements IDataProtectionKeyContext — adds DbSet<DataProtectionKey> DataProtectionKeys
  2. AddDataProtection().PersistKeysToDbContext<PayGoHubDbContext>() in Program.cs
  3. Migration creates "DataProtectionKeys" table (quoted — EF Core uses the exact name)

All containers now share one key ring via the database.

4 — Conditional OAuth registration

Problem: AddGoogle() throws ArgumentException: The value cannot be an empty string (ClientId) at startup when credentials are not configured — blocking CI builds and local dev without OAuth setup.

Fix:

var hasGoogleOAuth = !string.IsNullOrEmpty(googleClientId) && !string.IsNullOrEmpty(googleClientSecret);
if (hasGoogleOAuth) { authBuilder.AddGoogle(options => { ... }); }

The app boots and serves in cookie-only mode when OAuth credentials are absent.

Database Schema

Core entities with soft delete (DeletedAt) and audit timestamps (CreatedAt, UpdatedAt):

EntityKey fields
Customername, phone, region, account_number, country, currency
Paymentamount, method (M-Pesa/MoMo/Bank/Cash), status
Loanprincipal, interest rate, balance, status
Installationsystem type (SHS-80W–200W), scheduled/completed dates
Deviceserial, battery health, sync status
ActivityLogentity-scoped audit entries with icon/color metadata
DataProtectionKeysASP.NET Data Protection key ring (Cloud Run persistence)