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=50Engineering 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:
PayGoHubDbContextimplementsIDataProtectionKeyContext— addsDbSet<DataProtectionKey> DataProtectionKeysAddDataProtection().PersistKeysToDbContext<PayGoHubDbContext>()inProgram.cs- 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):
| Entity | Key fields |
|---|---|
| Customer | name, phone, region, account_number, country, currency |
| Payment | amount, method (M-Pesa/MoMo/Bank/Cash), status |
| Loan | principal, interest rate, balance, status |
| Installation | system type (SHS-80W–200W), scheduled/completed dates |
| Device | serial, battery health, sync status |
| ActivityLog | entity-scoped audit entries with icon/color metadata |
| DataProtectionKeys | ASP.NET Data Protection key ring (Cloud Run persistence) |