OpenPetra Modernization — Executive Summary
OpenPetra is a mature open-source ERP platform serving non-profit organizations worldwide. Its Finance — Gift Processing subsystem handles donation batch management, multi-currency gift processing, tax-deductible receipt generation, and general ledger integration — core revenue operations that must continue uninterrupted during modernization. Today this subsystem runs on .NET Framework 4.7 with ASP.NET Web Services (SOAP/.asmx), a jQuery front-end, and Mono/FastCGI hosting — a stack that limits developer recruitment, blocks modern browser capabilities, and carries 7 architectural concerns alongside 28 outdated dependencies, 20 of which are end-of-life and retired outright by the target (.NET Framework 4.7, Mono, jQuery 3.6, NAnt, ASMX SOAP, TPetraPrincipal session auth, browserify, axios with two known CVEs, and 12 polyfill / connector / build-tool packages).
This report recommends a classical strangler-fig modernization to .NET 10, Angular 20, and Azure App Service with PostgreSQL preserved. Azure API Management incrementally shifts gift-processing traffic from legacy SOAP endpoints to modern REST APIs — the posted/unposted batch state machine is the natural routing key, and Debezium + Azure Service Bus carry change events to the modernized side during coexistence. The subsystem decomposes into 2 services — Gift Processing API and Receipt Generation Service — validated by a three-perspective (Domain-Driven Design, Technical, Business) agent consensus scoring 7.8/10 with all three lenses converging on the same shape. Because the modernization is C# → C#, domain knowledge embedded in 279 source files transfers directly; the work is architectural — replacing typed datasets with EF Core, static utilities with injectable services, synchronous SOAP with async REST, and jQuery DOM manipulation with Angular reactive components.
Of 14 behavioral rules governing gift processing, 12 transfer with no behavioral change, 1 (BR-GIFT-002 period validation) is a deliberate improvement, and 5 carry legacy NUnit test corroboration. The one rule needing remediation — BR-GIFT-006 confidential gift privacy enforcement — requires an authorization-policy mitigation pattern documented in Section 9. All 161 schema columns across 10 tables map to the target with zero fidelity findings, six deliberate width-widenings, and 27 cross-slice foreign-key constraints resolved by the project-level drop-constraints policy. Platform-affinity analysis surfaced 9 entries (8 ELIMINATE / 1 HYBRID / 0 PRESERVE) at 100% three-run consensus resolution. Concho executed roughly 135 MCP queries across 28 sub-agent dispatches in 11 phases; the resulting report passed final verification at 9.6/10.
What Happens Next — Automated Code Generation
This report is not the end of the process — it is the input to a companion agentic code generation workflow that turns the architectural guidance, schema decisions, business rule specifications, and UI transformation patterns documented here into runnable implementation artifacts: ASP.NET Core 10 services, EF Core entity models, Angular 20 components, PostgreSQL schemas, Dockerfiles, Bicep IaC templates, and GitHub Actions CI/CD pipelines.
The code generation workflow uses a BDD/TDD iterate-till-green approach: it generates code from the behavioral rule specifications (Given-When-Then), writes automated tests derived from those specs, executes the full test suite, and — if any test fails — identifies the discrepancy, corrects the generated artifact, and re-runs until all tests pass. A human never reviews a broken artifact. Only when the modernized subsystem passes all behavioral, integration, and smoke tests does the workflow present it for human review. It does not arrive and get polished — it arrives already verified.
Section Takeaways
| § | Section | Key Takeaway |
|---|---|---|
| 4 | Target Architecture | 2-service ASP.NET Core 10 deployment on Azure App Service (P1v3 Linux) with Angular 20 SPA, Azure Database for PostgreSQL Flexible Server, EF Core 10 + Npgsql, and APIM-based strangler-fig routing — Bicep IaC, OpenTelemetry + Application Insights observability, Polly resilience, Managed Identity service-to-service auth, and slot-swap deployments built in. |
| 5 | Platform Affinity Analysis | 9 platform-driven entries reconciled across 3 independent runs at 100% consensus (0 split decisions): 8 ELIMINATE / 1 HYBRID / 0 PRESERVE. Every platform-driven limitation is either retired (.NET 4.7 runtime ceiling, Mono FastCGI, ASMX, jQuery + Bootstrap 4 + i18next + browserify, typed datasets, Newtonsoft.Json) or mitigated (the multi-currency engine becomes a hybrid). |
| 6 | Technical Debt Analysis | 7 architectural concerns (1 high / 4 medium / 2 low) and 5 architectural strengths cataloged; 28 dependencies audited — 10 removed, 10 replaced, 7 upgraded, 1 outside the slice; 1 known-CVE entry (axios 0.21.4) is removed entirely. The XML-driven schema, validation engine, and integration-test infrastructure are preserved as strengths. |
| 7 | UI/UX Transformation | Three legacy jQuery + AngularJS screens (GiftBatches, GiftDetailEntry, MotivationPicker) collapse into a single Unified Gift Batch Workspace with donor autocomplete, live multi-currency totals, live period validation, a 0–100 tax-deductible clamp, and a pre-commit GL journal preview the legacy system could never offer. Walked through six scenes from empty state to side-by-side legacy coexistence. |
| 8 | Code Translation | Same-language C# modernization — typed datasets become EF Core entities, static utilities become injectable services, SOAP / .asmx endpoints become async Minimal APIs, string-concatenated SQL becomes parameterized LINQ, Newtonsoft.Json becomes source-generated System.Text.Json, NUnit becomes xUnit + WebApplicationFactory + Testcontainers. |
| 9 | Business Rules Analysis | 14 rules (4 validation / 3 calculation / 2 state transition / 3 workflow / 2 authorization) with formal Given-When-Then specs at mean confidence 0.78; 5 carry legacy NUnit test corroboration; 12 transfer verbatim, 1 (BR-GIFT-002 period validation) is a deliberate improvement, 1 (BR-GIFT-006 confidential gift privacy) requires an explicit authorization-policy + DTO redaction mitigation. |
| 10 | Data Mapping Strategy | 161 columns across 10 tables (8 preserved + 2 new-operational) mapped from petra.xml typed datasets to EF Core entities on PostgreSQL; 0 fidelity findings, 6 declared width-widenings for UTF-8 + modern usage, and 27 cross-slice foreign keys resolved via the project-level drop-constraints policy with application-layer validation through the strangler-fig bridge. |
| 11 | Modernization Strategy & Coexistence | Classical strangler-fig via Azure API Management with the posted/unposted batch state machine as the routing key, Debezium + WAL log-tail CDC on the 8 gift tables, and Azure Service Bus session-ordered topics carrying sage-domain-event-v1 envelopes. Reads migrate before writes; consumer idempotency on natural composite keys; six-phase choreography with explicit per-phase rollback. |
| 12 | How Concho Enabled This Analysis | Three-way comparison (Concho + Claude / Claude Code alone / Manual Review) across 6 capabilities and 6 worked examples. Concho’s Concho Context Graph surfaced the three non-idempotent write rules in 2 queries — the discovery that ruled out symmetric dual-write and selected classical strangler-fig. ~135 Concho queries vs an estimated 16–29 weeks of senior-architect manual effort. |
1. Introduction
1.1 About This Document
This document is a modernization stakeholder review for the Finance — Gift Processing subsystem of OpenPetra (project key petra), an open-source administrative ERP for non-profit organizations. It documents the path from OpenPetra's legacy n-tier .NET Framework 4.7 implementation — ASP.NET Web Services (.asmx / SOAP), code-generated data access driven by an XML schema definition, and a JavaScript/jQuery web client — to a modern .NET 10 + Angular 20 stack targeted at Azure App Service, with PostgreSQL as the primary data store.
The analysis was produced by a multi-agent modernization workflow that queries the Concho Context Graph for codebase intelligence at every phase — architecture, entities, business rules, integration patterns, technical debt — and then composes a target architecture, code-translation examples, data-mapping strategy, and a legacy/modern coexistence plan grounded in that evidence. Every claim in this report traces back to a Concho MCP query, an artifact identifier, or a generated handoff file in run 004's workspace.
1.2 Candidate Selection Process
OpenPetra spans 27 documented business function subjects covering CRM, financial management, gift processing, conference administration, personnel, sponsorship, and reporting. Picking the right modernization slice is itself an architectural decision: too narrow and the demo is unconvincing; too broad and the strangler-fig boundary becomes ill-defined. Section 2 (Modernization Scope) and Appendix A (Multi-Agent Subsystem Selection) document a three-run consensus selection in which independent agent runs each scored every documented subsystem on revenue impact, behavioural-rule density, coupling, and tractability, then voted. Finance — Gift Processing emerged as the consensus pick: it is OpenPetra's revenue-generating core, has the densest catalog of formalizable business rules, and has clean integration seams to General Ledger and Partner Management that make a strangler-fig boundary viable.
1.3 Report Contents
- Executive Summary — one-page framing for non-technical stakeholders.
- Section 1 — Introduction (this section).
- Section 2 — Modernization Scope — subsystem boundary, in-scope/out-of-scope, target stack.
- Section 3 — Legacy System Analysis — Concho-vended architecture, entities, integrations.
- Section 4 — Target Architecture — .NET 10 services on Azure App Service.
- Section 5 — Platform Affinity Analysis — how each legacy element maps onto the target stack.
- Section 6 — Technical Debt Analysis — what carries forward, what gets retired.
- Section 7 — UI/UX Transformation — jQuery forms to Angular 20 components.
- Section 8 — Code Translation — concrete C# legacy-to-modern examples.
- Section 9 — Business Rules Analysis — behavioural rules in Given-When-Then form.
- Section 10 — Data Mapping Strategy — schema translation and slice-boundary FK policy.
- Section 11 — Modernization Execution & Legacy/Modern Coexistence — strangler-fig sequencing.
- Section 12 — How Concho Enabled This Analysis — the Concho capability story.
- Appendix A — Multi-Agent Subsystem Selection; Appendix B — Service Architecture; Appendix C — Deployment.
1.4 What This Report Does NOT Include
To keep the stakeholder review focused on architectural and behavioural decisions, the following are deliberately out of scope for this document:
- Cost estimates. No Azure consumption forecasts, licensing models, or labour-day pricing — those depend on tenancy decisions and SLAs that have not yet been negotiated.
- Timelines and project plans. No Gantt charts, sprint plans, or release dates — the report defines what needs to happen and in what order, not when.
- Deployment plans for the existing OpenPetra installations. Migration runbooks, cutover scripts, and data-extraction tooling for live customer instances are downstream of architectural alignment.
- Organizational change management. Training plans, role redefinitions, and operational hand-offs are owned by the customer and depend on staffing.
1.5 About OpenPetra (Petra)
OpenPetra is a free, open-source administrative system that helps non-profit organizations manage donor contacts, gift processing, accounting, sponsorship programs, conference logistics, publications, and tax-compliant receipting. It was conceived as an ERP for mission organizations and is notable for its multi-currency, multi-country tax compliance features and its International Clearing House mechanism that minimizes currency-exchange charges between sister organizations. The codebase is built around an unusual but highly disciplined pattern: a single master XML schema (db/petra.xml, 24,924 lines) drives code generation of typed datasets, ORM layers, and validation classes across the entire system.
Project Overview — Concho Cycle 23 Snapshot
| Project name | Petra (OpenPetra) |
| Total lines of code (analysed) | 572,757 LOC (architecture-tree breakdown: 400,437 LOC after excluding non-source assets) |
| Primary implementation language | C# (.NET Framework 4.7) — 517 files (39.4% of cataloged files) |
| Front-end | JavaScript / jQuery (56 files, 4.3%); HTML/CSS form templates |
| Client-server protocol | ASP.NET Web Services (.asmx, SOAP) and HTTP-RPC web connectors |
| Data stores supported | PostgreSQL (primary), MySQL, SQLite — multi-RDBMS abstraction layer |
| Business function subjects (documented) | 27 subjects spanning Finance, Partner, Sponsorship, Conference, Personnel, Reporting, System Management |
| Aggregate roots (Concho entity model) | 48 aggregate roots across 34 bounded contexts (average 6.4 relationships per entity) |
| Business rules catalogued by Concho | 216 business rules in the Business Rule Matrix artifact (overall mean confidence 0.70) |
| Integration boundaries | 150–151 integration points (83 bidirectional; 28% file-based, 25% web-connector RPC) |
| Selected subsystem for this report | Finance — Gift Processing |
| Target stack | ASP.NET Core 10 (server) + Angular 20 (client), PostgreSQL, Azure App Service |
| Concho source | Concho Context Graph, project petra cycle 23 |
1.6 Stakeholder Reading Guide
1. This Is a Modernization Stakeholder Review
This document is the output of an automated multi-agent modernization workflow — analysis, target-architecture design, behavioural-rule extraction, code translation, data mapping, and a legacy/modern coexistence plan, all composed in a few hours from a single Concho Context Graph of the OpenPetra codebase. It is structured as a stakeholder review — the way a senior architect would present a modernization proposal to an organization for feedback and alignment.
- Executives see the business case, the scope of the Gift Processing slice, and the modernization strategy at a glance.
- Architects see the target .NET 10 / Angular 20 design on Azure App Service, the platform-affinity table, and the service decomposition rationale.
- Developers see concrete C# and TypeScript code translations, Given-When-Then business-rule specifications, and data-mapping examples grounded in OpenPetra's typed-dataset / ORM-generation patterns.
- Infrastructure teams see the Azure App Service deployment pattern, the PostgreSQL data tier, and the legacy/modern coexistence bridge that lets a live OpenPetra instance keep operating during a strangler-fig cutover.
2. Human-in-the-Loop Iteration
This stakeholder review is designed to be read, challenged, and iterated. Disagree with the subsystem boundary? Adjust the scope in Section 2 and the agents will replan. Want a different Azure pattern (App Service vs. Container Apps vs. AKS)? Swap the target profile and rerun. Think a particular business rule was misclassified? Drop it from the catalog, add a counter-example, and the rule-extraction phase will reconcile. The point of the document is to compress weeks of manual architectural discovery into a single artifact that can absorb stakeholder feedback at the speed of conversation, rather than at the speed of a consulting engagement.
And because this is a Claude Code workflow, iterating is a conversation. Tell Claude — in plain language — “retarget this to ECS Fargate on AWS instead of Azure App Service,” and it does the right thing: it knows the framework’s structured set of agents, skills, prompts, and stored handoff artifacts, so it reruns only the steps that actually change (target architecture, platform affinity, deployment, IaC) and leaves the language-level analysis and business-rule catalog untouched. The same applies to swapping a UI framework, a data-access library, or an auth pattern. For finer control, the decisions live in low-level JSON artifacts you can edit directly — and Claude can tell you exactly which file and field to change. There is no proprietary console to learn: the operating principles are encoded in the framework, and Claude already knows how to drive them.
3. This Report Feeds Automation — A Human Never Reviews a Broken Artifact
Every section of this document produces structured handoff artifacts — the chosen subsystem boundary, the platform-affinity decisions, the Given-When-Then behavioural-rule catalog, the entity-to-schema mapping, the UI storyboard JSON, the slice-boundary FK policy, the coexistence-bridge declarations — that feed directly into a companion agentic code-generation workflow. That workflow consumes the handoffs and emits runnable artifacts on Azure App Service:
- Services — ASP.NET Core 10 web APIs scaffolded from the platform-affinity decisions and the Concho-vended entry-point catalog.
- Entity models — C# domain types and EF Core configurations derived from OpenPetra's typed-dataset definitions.
- UI components — Angular 20 standalone components rendered from the storyboard JSON, replacing the legacy jQuery forms.
- Database schemas — PostgreSQL DDL generated from the entity model with the configured slice-boundary FK policy (
drop-constraintsat the Gift / Partner seam). - Dockerfiles & IaC templates — container build files and Bicep/Terraform for Azure App Service, Azure Database for PostgreSQL, and Application Insights observability.
- CI/CD pipelines — GitHub Actions or Azure DevOps definitions that build, test, and deploy the modernized stack.
- Behavioural tests — xUnit tests for the rule catalog and Playwright end-to-end tests for the Angular UI.
The code-generation workflow runs a BDD/TDD iterate-till-green loop per artifact. The tests are generated from the derived business rules — every Given-When-Then specification in Section 9, plus the field-level rules auto-derived from the UI inventory in Section 9.8, becomes an executable xUnit or Playwright assertion. The workflow generates code from those same specs, runs the suite against the generated code, inspects failures, self-corrects, and re-runs until green. Because both sides — the implementation and its test oracle — are derived from one source of truth, the rule catalog validates itself.
And an adversarial validation agent runs at every step, not just at the end. The same discipline that produced this report — every generation step is followed by an independent validator agent that tries to break the output, checks it against the source evidence, and either passes it or sends it back for correction — carries straight into code generation. Schema mapping, service scaffolding, UI rendering, and the rule-to-test translation are each adversarially checked before the next step consumes them. A human never reviews a broken artifact. What you see in the downstream review is code that has already passed its own behavioural tests and survived an adversarial validator at each stage of its construction.
This means Sections 7 (UI), 8 (Code Translation), 9 (Business Rules), 10 (Data Mapping), and Appendices B–C are both human-readable evidence and machine-consumable specifications. The same artifact that aligns stakeholders is the artifact that drives the code generator.
2. Modernization Scope
This report leverages Concho's deep insight into all 27 subsystems in Petra (OpenPetra). A variety of criteria was applied against each one in order to find the right balance between technical feasibility, business value, and migration risk in order to select a candidate subsystem for this first migration project.
Three independent evaluation passes were conducted — standard weighted scoring, technical-feasibility emphasis, and business-value emphasis. Two of the three runs (Runs 2 and 3) selected the same candidate; Run 1 dissented in favor of a lower-risk alternative. The 2/3 majority prevails, with Run 1's case for the alternative explicitly documented in Appendix A: Multi-Agent Subsystem Selection.
Recommended Modernization Candidate: Finance — Gift Processing
Consensus: 2 of 3 runs | Average Weighted Score: +2.47 (across 3 independent runs; rank 1 of 27 by majority recommendation)
Risk: 5.8 / 10 (moderate) | Feasibility: 7.0 / 10 (high) | Strategic Value: 9.0 / 10 (very high)
2.1 Why Finance — Gift Processing
Gift processing is OpenPetra's flagship donor workflow. A donor sends money, the back office posts a gift batch with motivations and tax-deductibility classification, and the system generates annual tax receipts. It is the defining operational capability of a non-profit ERP, spanning bank import through GL posting through year-end receipting.
This is the right pilot for four reasons:
- Marquee business value. Donations are the primary revenue mechanism for non-profit organizations. Modernizing gift processing directly impacts the headline workflow and provides the highest-visibility demonstration of modernization ROI to executive sponsors and boards.
- Pattern reusability across the programme. Gift Processing exercises every spine pattern the rest of the modernization needs: typed-dataset ORM to Entity Framework Core, ASMX web service to ASP.NET Core REST, AngularJS jQuery forms to Angular 20 reactive components, code-generated DAL re-targeting, batch-state machinery for strangler routing, multi-currency and tax-receipt rendering. Patterns proven here transfer directly to Donations Processing, Finance — Banking, Finance — Budgeting, and Reporting — Financial Statements.
- Clean strangler boundary. The implementation is concentrated in a small set of named seams —
TGiftTransactionWebConnector,TGiftBatchFunctions, and theAGift/AGiftBatch/AGiftDetailtables. The posted/unposted batch state machine gives the strangler-fig migration a natural routing key. - Right-sized scope. 279 files is meaningful enough to validate the modernization approach, yet bounded enough to complete as a focused pilot. Substantially smaller than Finance — Accounting (370 files) or System Management — Users (390 files), yet large enough to demonstrate real migration capability — unlike Sponsorship — Program Management (12 files) or Hospitality — Accommodation (5 files).
2.2 Technical Scope and Dependencies
Finance — Gift Processing comprises 279 C# files. Its primary integration affinity is to Donations Processing (0.8) and Finance — Accounting (0.6). The web-connector RPC pattern maps cleanly to ASP.NET Core controllers, and the batch posting workflow provides a natural state-based seam for the classical strangler-fig migration strategy specified in the architecture plan. Integration with the legacy MFinance GL is direct but narrow (batch posting contract), enabling a strangler boundary that preserves legacy ledger writes while the new system fronts donor entry and receipting.
2.3 Alternative Methodology: Multi-Agent Subsystem Selection
Finance — Gift Processing was identified through Concho's multi-agent consensus methodology, where three independent AI agents evaluate all subsystems from different perspectives using quantified criteria, then reach consensus on optimal modernization priorities. Two of the three runs converged on Gift Processing; Run 1 advocated for Finance — Banking as a lower-risk alternative. See Appendix A: Multi-Agent Subsystem Selection for the complete three-run analysis, scoring matrices, divergence rationale, and the credible-alternative case for Banking.
3. Legacy System Analysis
petra, cycle 23) analysed 572,757 lines of code across 1,396 files at 100% coverage and surfaced a precise picture of OpenPetra's legacy shape: a layered monolith with a code-generation pipeline driven by a 24,924-line master XML schema, 48 aggregate roots across 34 bounded contexts, 216 business rules in the Business Rule Matrix artifact (mean confidence 0.70), 150–151 integration boundaries (83 of them bidirectional), 100 workflows (70% high-complexity), and 148 entry points dominated by REST (47) and SOAP (25). The architecture-layer diagram and data-flow diagram in this section are Concho-vended artifacts rendered verbatim — not agent reconstructions. This section establishes the baseline knowledge that drives every decision in Sections 4–12: target architecture, platform-affinity mapping, technical-debt triage, code translation, business-rules formalization, data-mapping strategy, and the legacy/modern coexistence plan.
3.1 High-Level System Architecture
OpenPetra implements a layered monolithic architecture with extensive code generation driven by XML schema definitions. The Database Schema Definition (db/petra.xml) serves as the single source of truth, orchestrating a Code Generation Pipeline that produces strongly-typed datasets, ORM layers, and RPC interfaces across every layer. The result is a data-centric design: the schema is not merely persistence, it is the architectural backbone.
Five characteristics stand out from the Concho analysis:
- Report-heavy presentation layer — XML report templates account for 42,307 lines (12.6% of source code), reflecting the regulatory and transparency demands of non-profit operations.
- Multi-database abstraction — a unified data-access layer supports PostgreSQL, MySQL, and SQLite via a single typed-dataset / ORM-generation pattern (
TDBTypeenum,CommonTypes.ParseDBType()). - Extensive validation framework — 14,465 lines (4.3% of source code) of cross-domain validation, ensuring data integrity for financial operations.
- Bidirectional integration patterns — 83 of 150 integration points are bidirectional, indicating heavy synchronous request/response coupling.
- Legacy .NET Framework foundation — .NET Framework 4.7 with .NET Remoting underpinning RPC-based client-server communication.
The Mermaid diagram below is the Concho-vended architecture-layer diagram retrieved from the Concho Context Graph artifact store.
Source: Concho Context Graph — architecture_layer_diagram artifact, confidence 0.95.
7,759 lines
presentation controller] RT[XML Report Templates
42,307 lines
computation engine] FT[Form Templates
5,850 lines] ET[Email Templates
186 lines] LOC[Localization
3,390 lines] end subgraph AL["Application Layer - 123,408 lines"] FE[Finance Operations Engine
54,657 lines
computation engine] PM[Partner Management Operations
30,146 lines
service facade] WS[Web Service API Layer
8,982 lines
service facade] CM[Conference Management
2,756 lines] SA[System Administration
5,542 lines] end subgraph CC["Cross-Cutting Concerns - 51,879 lines"] CU[Common Utilities Framework
16,159 lines
shared kernel] VF[Business Rules Validation
14,465 lines
domain core] EH[Exception Handling Framework
8,550 lines
infrastructure plumbing] PF[Document Generation Engine
6,735 lines
computation engine] SF[Security Framework
2,067 lines] end subgraph DL["Data Layer - 48,404 lines"] DS[Database Schema Definition
24,924 lines
data access] DA[Database Abstraction Layer
9,887 lines
data access] BD[Base Data
1,897 lines] CH[Caching
2,320 lines] DU[Database Upgrades
1,684 lines] end subgraph IL["Integration Layer - 15,288 lines"] FIE[File Import Export Gateway
9,830 lines
integration gateway] BI[Bank Import
4,250 lines] SE[SEPA Export
614 lines] EA[External APIs
88 lines] end subgraph INF["Infrastructure - 15,496 lines"] SC[Server Configuration
3,281 lines] SM[Session Management
2,204 lines] RF[Remoting Framework
1,674 lines] SFR[Security Framework
2,725 lines] WEB[Web Services
1,297 lines] LOG[Logging
1,172 lines] end subgraph BLD["Build & Deployment - 36,986 lines"] CG[Code Generation Pipeline
14,508 lines
computation engine] DT[Development Tools Suite
7,519 lines
computation engine] BS[Build Scripts
4,307 lines] DM[Dependency Management
6,690 lines] end WC --> WS RT --> FE RT --> PM WC --> FE WC --> PM FE --> VF PM --> VF WS --> FE WS --> PM FE --> DA PM --> DA WS --> DA DA --> DS AL -.-> CC PL -.-> CC DL -.-> CC IL -.-> CC FE --> FIE PM --> FIE WS --> IL AL --> INF PL --> INF IL --> INF DS -.->|entanglement| CG CG --> DS CG --> DA CG --> AL classDef majorElement fill:#e1f5fe classDef secondaryElement fill:#f3e5f5 classDef infrastructureElement fill:#fff3e0 class FE,RT,PM,DS majorElement class CU,CG,VF,WC,DA,FIE,WS,EH,DT,PF secondaryElement class INF,BLD infrastructureElement
3.2 Architectural Layers
Concho's architecture-tree analysis decomposes the codebase into eight top-level layers with a line-count distribution that immediately reveals where the legacy weight lives.
| Layer | Lines of Code | Share | Largest sub-cluster (per Concho) |
|---|---|---|---|
| Application Layer | 116,775 | 29.2% | Finance Management 61,632 LOC (General Ledger 22,615; Gift Processing 17,103) |
| Presentation Layer | 64,648 | 16.1% | Report Templates 43,521 LOC (Finance Reports 16,674) |
| Data Layer | 48,033 | 12.0% | Database Schema 24,925 LOC (the master petra.xml) |
| Cross-Cutting Concerns | 40,804 | 10.2% | Validation Framework 9,535 LOC; Common Utilities 9,214 LOC |
| Infrastructure | 36,977 | 9.2% | Web Services 12,994 LOC; Security Framework 7,165 LOC |
| Build & Deployment | 34,112 | 8.5% | Development Tools 13,819 LOC; Code Generation 12,379 LOC |
| Test | 26,849 | 6.7% | Integration Tests 15,402 LOC; Test Utilities 11,447 LOC |
| Documentation | 19,354 | 4.8% | Database Documentation 15,820 LOC (schema diagrams) |
| Integration Layer | 12,885 | 3.2% | File Import/Export 7,413 LOC; Bank Import 3,876 LOC |
| Total (architecture-tree scope) | 400,437 | 100% | Total codebase (incl. assets): 572,757 LOC |
Three observations matter for modernization planning:
- Finance Management dominates application logic (61,632 LOC), and within it Gift Processing (17,103 LOC) sits next to General Ledger (22,615 LOC) — the chosen subsystem boundary therefore has a real, measurable presence rather than being a token slice.
- Report Templates are the largest single sub-cluster (43,521 LOC). They use a proprietary NO-SQL template syntax that escapes static analysis; the modernization plan needs an explicit strategy for them (see Section 6 and Section 10).
- Code Generation is itself 12,379 LOC. The build system generates a significant share of the C# in the Data Layer and Application Layer — meaning the modernization plan cannot simply "translate the code"; it must decide whether to keep the generation pipeline, port it, or replace it (Section 4, Section 8).
3.3 Data Entities
Concho's entity extraction discovered 48 aggregate roots across 34 bounded contexts with 100% STRUCTURAL evidence (i.e., every root is grounded in a concrete code structure, not inferred). The average is 6.4 relationships per entity, and the domain enforces 265 business rules across these roots at an average confidence of 0.80. The table below lists the highest-confidence aggregate roots that participate in or border the Finance — Gift Processing slice.
| Aggregate root | Confidence | Relationships | Rules | Role in Gift Processing |
|---|---|---|---|---|
| Partner | 0.91 | 17 | 15 | Donor / recipient / family / organisation; addresses; consent |
| Gift | 0.87 | 7 | 7 | Individual donation transactions (one-time and recurring) with multi-currency / SEPA mandate handling |
| GiftBatch | 0.87 | 5 | 5 | Batch lifecycle (DRAFT → VALIDATED → READY_FOR_POSTING → POSTED → COMPLETED); tax-receipt generation |
| GeneralLedger | 0.89 | 8 | 8 | Posting target for gift batches; chart of accounts and cost centres |
| FinancialLedger | 0.85 | 6 | 5 | Multi-currency operations; retained earnings; cost-centre hierarchy |
| PartnerFamily | 0.83 | 5 | 4 | Household / family aggregation for receipting |
| UserAccount | 0.91 | 10 | 8 | Identity of the user posting a gift batch (authentication / authorization) |
| UserPermission | 0.85 | 7 | 5 | Module permissions controlling who may import, validate, or post |
| DataImportExport | 0.87 | 4 | 5 | CSV / XML / Excel import of gift batches; GDPdU export |
| Report | 0.87 | 10 | 10 | XML-template reporting (e.g., donor statements, gift receipts) |
Source: Concho search_entities (aggregate_root, confidence ≥ 0.75) plus the business_domain_map and bounded_context_map artifacts. The full Concho catalogue contains 40 aggregate roots at confidence ≥ 0.75 across all domains; only those participating in or bordering the Gift Processing slice are shown here.
The ER diagram below is derived from Concho entity discovery (relationships above are extracted from the Concho aggregate-root metadata; cardinalities follow the OpenPetra typed-dataset definitions referenced by Concho as PPartnerTable, AGiftBatchTable, AGiftTable, AGiftDetailTable). It is intentionally scoped to the Gift Processing slice and its immediate borders.
Derived from Concho entity / architecture discovery (search_entities + business_domain_map). Not a single Concho-vended artifact; constructed by the project-intel agent from Concho data.
3.4 Business Rules & Behavior
Concho's Business Rule Matrix artifact (confidence 0.92) catalogues 216 business rules distributed across 48 aggregate roots and 100 workflows within 33 bounded contexts:
- 43% validation rules — data integrity, format constraints, domain invariants.
- 17% authorization rules — module-level access control, permission gates.
- 16% derivation rules — computed fields, currency conversions, allocations.
- The remaining 24% cover orchestration, lifecycle, and integration rules.
- Overall mean confidence: 0.70; 40 rules are explicitly flagged as compliance-relevant.
Section 9 (Business Rules Analysis) populates this rule catalogue with explicit Given-When-Then specifications for the rules that govern the Gift Processing slice — sequential batch numbering, financial-period validation, partner existence, motivation-code assignment, multi-currency exchange-rate lookup, SEPA mandate compliance, posting-status transitions, and tax-receipt eligibility. These specifications then feed the BDD/TDD code-generation loop described in Section 1.6.
Placeholder: the full Given-When-Then catalogue is generated by the behavioral-rules phase (Section 9) and is not duplicated here.
3.5 Technology Stack
OpenPetra's documented technology footprint as catalogued by Concho:
| Layer / concern | Technology | Prevalence (per Concho) | Notes |
|---|---|---|---|
| Server language | C# on .NET Framework 4.7 | 39.4% (517 files) | Primary implementation; locked to legacy .NET Framework |
| Client-server protocol | ASP.NET Web Services (.asmx / SOAP) + HTTP-RPC web connectors + .NET Remoting | 12 .asmx files; 4,095 LOC remoting framework | Session-managed via OpenPetraSessionID cookie; binary-serialized SortedList parameters |
| REST surface | Server — REST Services (Web Connectors) | 47 REST entry points (per entry_point_catalog) | Coexists with 25 SOAP entry points; 148 entry points in total |
| Front-end | JavaScript / jQuery + HTML/CSS form templates | 4.3% (56 files) | Bootstrap modals, AJAX over HTTP-RPC; TypeScript present in selected modules |
| Primary data store | PostgreSQL | 4.5% file share (59 files) | Target persistence for modernization |
| Alternate data stores | MySQL, SQLite | Supported via the multi-RDBMS abstraction layer | |
| Schema definition | XML (Data Models) | 20.5% (269 files) | Master schema db/petra.xml (24,924 LOC) drives all code generation |
| Build system | NAnt | 1.4% (18 files) | Custom build pipeline including ORM & interface generation |
| Reporting | XML report templates (proprietary "NO-SQL" syntax) | 43,521 LOC of templates | Escapes static analysis; PDF / HTML generation via HtmlAgilityPack |
| Testing | NUnit unit/integration tests + Cypress E2E | 31 test files / 27,050 LOC (1:19 test-to-source) | Per Concho Testing Profile artifact |
| Banking integration | CAMT, MT940, SEPA XML parsers | 3,876 LOC bank import; 245 LOC SEPA export | European direct-debit and statement formats |
| Security / auth | Custom password hashing, .NET Remoting auth, partner ACLs | 7,165 LOC security framework | Concho Security Posture flagged configurable SSL bypass & 226 hard-coded constraints |
Across all 34 technology subjects, three observations frame the target-architecture decisions in Section 4: (1) the language is already C#, so the move to .NET 10 is a runtime/library port rather than a re-write; (2) the front-end is jQuery, so an Angular 20 rebuild is unavoidable but localized; (3) the data tier already runs PostgreSQL, so the modernization can lift-and-shift schemas while flattening the multi-RDBMS abstraction layer.
3.6 Integration Patterns
Concho's Integration Boundary Map (confidence 0.95) documents 150–151 integration points across 216 files: 28% file integrations, 25% web connectors, 88.7% supported by STRUCTURAL evidence, average confidence 0.71. Bidirectional connections dominate (83 of 151), indicating heavy synchronous request/response coupling rather than event-driven decoupling. The dominant integrations are TSponsorshipWebConnector, TImportExportWebConnector, the OpenPetra SOAP Web Service, Partner Contact Search, and TGiftTransactionWebConnector.
The data-flow diagram below is the Concho-vended data_flow_diagram artifact. It highlights four primary data channels: web-form imports, automated bank imports, gift-batch CSV imports, and report generation. All four converge on the Finance Operations Engine and Partner Management Operations, which persist through the multi-RDBMS Database Abstraction Layer.
Source: Concho Context Graph — data_flow_diagram artifact, confidence 0.88.
Three properties of this picture become design constraints for the modernization:
- The Gift Processing slice has a sharp inbound seam (CSV/MT940/CAMT) and a sharp outbound seam (GL posting). Both are good strangler-fig boundaries: replaceable independently of the rest of the application.
- RPC web connectors are the dominant client/server protocol, not REST or events. The target architecture replaces these with explicit REST endpoints on ASP.NET Core 10 (Section 4 and Appendix B).
- The Code Generation Pipeline is wired into the runtime data path, not just the build — the ORM that runs is generated by the same pipeline that builds. The legacy/modern coexistence plan in Section 11 has to keep that pipeline working on the legacy side while the modern side migrates to EF Core.
4. Target Architecture
This section defines the target Azure App Service architecture for the modernized Finance — Gift Processing subsystem (279 files, DonationManagement bounded context), including design principles, technology stack, code-level architecture decisions, and target data model. A multi-agent service architecture analysis determined the optimal decomposition: 2 microservices for this modernization — Gift Processing API and Receipt Generation Service.
The 2-service architecture was selected through unanimous consensus of Domain-Driven Design, Technical Architecture, and Business Capability analyses, achieving a composite decision score of 7.8 / 10 (gate: ≥ 7.0). Section 4.5 summarizes the decision; the full multi-perspective analysis — per-lens proposals, rejected alternatives, per-service API contracts, and the implementation roadmap — is documented in Appendix B: Service Architecture.
4.1 Target Architecture Overview
The modernized Gift Processing subsystem is deployed as a multi-service .NET 10 application on Azure App Service, replacing the legacy Mono/FastCGI-hosted ASP.NET Web Services (.asmx/SOAP) architecture with a modern REST API backed by Entity Framework Core on Azure Database for PostgreSQL. The Angular 20 single-page application replaces the legacy jQuery/AngularJS front-end, delivering a responsive donor management experience served as static files from the same App Service plan.
The architecture follows the classical strangler-fig modernization strategy: Azure API Management routes gift-processing traffic to the new .NET 10 services while legacy subsystems (GL posting, Partner management) continue operating on the existing infrastructure. As each subsystem is modernized, traffic shifts from legacy to modern without requiring a big-bang cutover. The DonationManagement bounded context (confidence 0.84) maps directly to the service boundary, with read-only Partner validation and one-way GL posting accessed through well-defined integration interfaces fronted by the strangler bridge.
Key Architecture Principles
- API-first design. All gift processing operations are exposed through ASP.NET Core Minimal APIs with OpenAPI documentation (Swashbuckle), enabling both the Angular SPA and future integrations to consume the same contract.
- Bounded context alignment. Service decomposition maps to the DonationManagement bounded context identified by Concho analysis, keeping gift batch processing, receipt generation, and recurring gift management within a cohesive service boundary.
- Same-language modernization. C# to C# transition (from .NET Framework 4.7 to .NET 10) preserves domain knowledge embedded in the existing codebase while modernizing the runtime, dependency injection, async patterns, and configuration model.
- Strangler-fig routing. Azure API Management owns the public surface and routes per-path: modern endpoints to the new App Services, unmodified routes (Partner, GL, Reporting until later phases) to the legacy server. This enables incremental traffic shifting without coordinated big-bang cutovers.
- Observability by default. OpenTelemetry tracing and Serilog structured logging with correlation IDs feed into Azure Application Insights, providing end-to-end visibility across gift processing workflows from bank import through GL posting and annual receipt generation.
- Infrastructure as Code. All Azure resources are defined in Bicep templates, enabling repeatable provisioning across development, staging, and production environments with slot-based blue/green deployments.
- Resilience through Polly. Transient fault handling with exponential backoff and circuit breaker patterns protect against downstream dependency failures (Partner validation, GL posting bridge), ensuring gift processing degrades gracefully rather than failing completely.
4.2 Target Architecture Diagram
graph TB
subgraph "Client Layer"
SPA["Angular 20 SPA
(Gift Batches, Recurring Gifts,
Motivations, Bank Import,
Annual Receipts)"]
end
subgraph "Edge & Gateway"
FD["Azure Front Door
(WAF + TLS Termination)"]
APIM["Azure API Management
(Strangler-Fig Routing)"]
end
subgraph "Azure App Service Plan (P1v3 Linux)"
API["Gift Processing API
(.NET 10 Minimal API)
- Batch CRUD & Posting
- Tax Deductibility Calc
- Multi-Currency Handling
- Recurring Gift Mgmt"]
RECEIPT["Receipt Generation Service
(.NET 10)
- Annual Receipt PDF
- HTML Template Processing
- Donor Statement Export"]
end
subgraph "Data & State"
PG[("Azure Database
for PostgreSQL
(Gift Batches, Gifts,
Gift Details, Motivations,
Recurring Gifts)")]
REDIS["Azure Cache
for Redis
(Session, Caching)"]
BLOB["Azure Blob Storage
(Receipt PDFs,
Import Files)"]
end
subgraph "Security & Config"
KV["Azure Key Vault
(Connection Strings,
API Keys)"]
ENTRA["Microsoft Entra ID
(User Auth)"]
end
subgraph "Observability"
AI["Azure Application Insights
(Traces, Metrics, Logs)"]
LAW["Log Analytics
Workspace"]
end
subgraph "Legacy Bridge (Strangler-Fig)"
LEGACY_PARTNER["Legacy Partner Service
(Read-Only Validation)"]
LEGACY_GL["Legacy GL Service
(One-Way Posting)"]
end
SPA --> FD
FD --> APIM
APIM --> API
APIM --> RECEIPT
APIM -.->|"unmodified routes"| LEGACY_PARTNER
APIM -.->|"unmodified routes"| LEGACY_GL
API --> PG
API --> REDIS
API --> BLOB
API --> KV
API --> ENTRA
API --> AI
API -.->|"REST/JSON"| LEGACY_PARTNER
API -.->|"REST/JSON"| LEGACY_GL
RECEIPT --> PG
RECEIPT --> BLOB
RECEIPT --> AI
AI --> LAW
classDef modern fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef azure fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef legacy fill:#fff3e0,stroke:#e65100,stroke-width:1px,stroke-dasharray:5
classDef client fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
class SPA client
class API,RECEIPT modern
class FD,APIM,PG,REDIS,BLOB,KV,ENTRA,AI,LAW azure
class LEGACY_PARTNER,LEGACY_GL legacy
Target Azure App Service architecture for Finance — Gift Processing — 2-microservice decomposition (Gift Processing API + Receipt Generation Service) from multi-agent consensus analysis (composite score 7.8/10). Full analysis in Appendix B.
4.3 Target Technology Stack
| Category | Technology | Version / SKU | Rationale |
|---|---|---|---|
| Compute | Azure App Service (Linux) | P1v3 (PremiumV3) | Managed PaaS with slot-based blue/green deployments, autoscale (2–6 instances), and always-on for production workloads. Lower operational ceiling than AKS for a small-fleet topology. |
| Server Runtime | .NET 10 (ASP.NET Core Minimal API) | 8.0 LTS | Same-language C# modernization from .NET Framework 4.7 preserves domain logic. Minimal APIs reduce ceremony versus MVC controllers. LTS support through November 2026. |
| Client Framework | Angular | 18 | Replaces legacy jQuery/AngularJS forms with a component-based SPA. Strong TypeScript typing, reactive forms for complex gift batch entry, and Angular CLI for standardized builds. |
| Database | Azure Database for PostgreSQL | Flexible Server (General Purpose, 2 vCores) | Legacy system already supports PostgreSQL as a backend through the multi-database abstraction layer. Managed service eliminates DBA overhead for patching, backups (7-day retention), and high availability. |
| ORM / Data Access | Entity Framework Core | 8 (Npgsql provider) | Replaces legacy typed datasets generated from db/petra.xml. Code-first migrations enable version-controlled schema evolution. Connection resiliency via execution strategy. |
| Caching | Azure Cache for Redis | Standard C1 | Session state, motivation code lookups, and exchange rate caching. Reduces database round-trips for frequently accessed reference data. |
| Object Storage | Azure Blob Storage | Standard LRS, Hot tier | Receipt PDF storage, bank statement import files (CSV, MT940, CAMT, ZIP), and HTML receipt templates. Replaces file-system storage from the legacy Mono deployment. |
| API Gateway | Azure API Management | Standard V2 | Strangler-fig routing: gift-processing requests to new services, unmodified subsystems to legacy. Rate limiting, idempotency key injection, and OpenAPI aggregation. |
| Edge / WAF | Azure Front Door | Premium | TLS termination, WAF in Prevention mode, and geo-routing for multi-region non-profit deployments. |
| Identity | Microsoft Entra ID + Managed Identity | — | User authentication via JWT Bearer tokens. Service-to-service auth via Managed Identity (no credential rotation). Replaces legacy session-based TOpenPetraOrgSessionManager. |
| Secrets | Azure Key Vault | Standard (RBAC) | Connection strings, API keys, and SMTP credentials stored with RBAC authorization. App Service references secrets via @Microsoft.KeyVault(SecretUri=...) syntax. Purge protection and 90-day soft-delete enabled. |
| Observability | Azure Application Insights + Log Analytics | 100% sampling, 90-day retention | OpenTelemetry traces + Serilog structured logs converge in Application Insights. End-to-end transaction correlation across gift batch entry, posting, and receipt generation. |
| IaC | Bicep | — | Azure-native declarative infrastructure. Simpler than ARM templates, tighter Azure integration than Terraform for this scope. |
| CI/CD | GitHub Actions | — | Build → Test → Deploy-Staging → Smoke-Test → Swap-to-Production pipeline with slot-swap rollback. |
The technology choices above are deliberately conservative for a pilot modernization: Azure App Service eliminates container orchestration complexity, PostgreSQL preserves the existing data engine, and the C#-to-C# path reduces translation risk. Each choice is proven at scale in Azure-hosted .NET workloads and provides a reusable pattern for subsequent subsystem modernizations (Finance — Banking, Finance — Accounting).
4.3.1 Target Code-Level Architecture Decisions
The following table documents code-level patterns and library choices that govern all generated code examples throughout this report (Sections 7–9 and Appendix C). These decisions are resolved from the Azure App Service architecture template's targetPatterns block (no project-specific overrides applied) and apply uniformly across all services in the subsystem.
| Category | Decision | Library / Tool | Rationale |
|---|---|---|---|
| API Framework | ASP.NET Core Minimal APIs for all HTTP endpoints | ASP.NET Core 10 Minimal API | Reduces boilerplate versus MVC controllers; natural mapping from legacy .asmx operations to MapPost/MapGet endpoint groups. OpenAPI auto-generation via Swashbuckle. |
| Input Validation | FluentValidation at all API boundaries | FluentValidation | Replaces legacy server-side TVerificationResultCollection pattern with declarative, testable validation rules. Applied to every request DTO entering the API layer. |
| Error Handling | Domain exceptions with RFC 7807 Problem Details responses | ASP.NET Core ProblemDetails + custom domain exceptions | Typed exception hierarchy (e.g., GiftBatchNotFoundException, PostingPeriodClosedException) replaces legacy numeric return codes and TVerificationResult objects. RFC 7807 provides machine-readable error responses. |
| Resilience | Polly for retry, circuit breaker, and timeout policies | Polly (exponential backoff + circuit breaker) | Protects calls to legacy Partner validation bridge and GL posting bridge. Exponential backoff for transient failures; circuit breaker prevents cascade failures during legacy outages. |
| Database Access | Entity Framework Core with code-first migrations | EF Core 10 + Npgsql + EF Core Migrations | Replaces petra.xml-generated typed datasets with strongly-typed DbContext models. Code-first migrations replace manual schema upgrade scripts (e.g., Upgrade202206_202207.cs). Connection resiliency via EF Core execution strategy. |
| Serialization | System.Text.Json for all request/response serialization | System.Text.Json (source-generated) | Built-in .NET 10 serializer with source-generated serialization for performance. Replaces legacy XML/SOAP serialization with JSON. Camel-case naming by convention. |
| Observability — Tracing | OpenTelemetry for distributed tracing | OpenTelemetry .NET SDK | Vendor-neutral CNCF standard. Auto-instrumentation for ASP.NET Core, EF Core, and HttpClient. Traces exported to Application Insights via OTLP. |
| Observability — Logging | Serilog with JSON structured logging and correlation IDs | Serilog + Serilog.AspNetCore + Serilog.Sinks.ApplicationInsights | Replaces legacy TLogging with structured, machine-parseable JSON logs. Correlation IDs from OpenTelemetry context injected into every log entry for trace-to-log correlation. |
| Observability — Metrics | OpenTelemetry metrics exported to Application Insights | OpenTelemetry .NET Metrics API | Business metrics (gifts processed, batches posted, receipts generated) alongside standard HTTP/DB metrics. Single pipeline for traces, logs, and metrics. |
| Health Checks | ASP.NET Core Health Checks with readiness, liveness, and startup probes | Microsoft.Extensions.Diagnostics.HealthChecks | Readiness probe (/health/ready) verifies PostgreSQL and Redis connectivity. Liveness probe (/health/live) confirms process responsiveness. Startup probe (/health/startup) gates traffic until initialization completes. |
| Configuration & Secrets | appsettings.json + environment variables + Azure Key Vault references |
ASP.NET Core Configuration + Azure Key Vault | 12-factor configuration via environment variables in App Service. Secrets stored in Key Vault with @Microsoft.KeyVault(SecretUri=...) reference syntax. No secrets in code or config files. |
| Inter-Service Communication | REST/HTTP/JSON (synchronous); Azure Service Bus (asynchronous, on-demand) | HttpClient + System.Text.Json; Azure.Messaging.ServiceBus | Synchronous REST for on-demand operations between services. Service Bus available for bulk receipt generation and event-driven scenarios (not provisioned by default; introduced only when an async slice is in scope). |
| Testing | xUnit with WebApplicationFactory for integration tests | xUnit + Microsoft.AspNetCore.Mvc.Testing | In-memory integration tests verify API contracts, validation rules, and database operations. 80% coverage target. Replaces legacy NUnit test infrastructure. |
| API Documentation | OpenAPI 3.0 specification auto-generated from endpoints | Swashbuckle.AspNetCore | Interactive Swagger UI for developer experience. Schema generated from Minimal API endpoint metadata and FluentValidation rules. |
| SPA Hosting | Angular 20 static files served from App Service | Angular CLI + App Service static file middleware | Angular app built by CI/CD and deployed alongside the .NET API. No separate static hosting service needed for a small-fleet topology. |
4.4 Target Data Model
The target data model is derived from Concho's analysis of the legacy petra.xml-generated typed datasets and the 11 SQL query files associated with the Finance — Gift Processing subsystem. The model normalizes the legacy schema into PostgreSQL-native types while preserving the core entity relationships: gift batches contain gifts, gifts contain gift details, and motivations categorize donations for financial reporting and tax compliance.
erDiagram
GIFT_BATCH {
int batch_id
int ledger_id
varchar batch_description
varchar batch_status "Posted | Unposted | Cancelled"
date gl_effective_date
varchar currency_code
decimal exchange_rate_to_base
int batch_year
int batch_period
timestamp created_at
timestamp updated_at
}
GIFT {
int gift_id
int batch_id
int gift_transaction_number
bigint donor_partner_key
date date_entered
boolean receipt_printed
boolean first_time_gift
varchar method_of_giving_code
varchar method_of_payment_code
varchar receipt_letter_code
varchar reference
}
GIFT_DETAIL {
int gift_detail_id
int gift_id
int detail_number
bigint recipient_partner_key
decimal gift_transaction_amount
decimal gift_amount_in_base_currency
decimal gift_amount_intl
varchar motivation_group_code
varchar motivation_detail_code
varchar cost_centre_code
varchar account_code
decimal tax_deductible_pct
boolean tax_deductible
varchar gift_comment_one
varchar gift_comment_two
varchar gift_comment_three
boolean modified_detail
boolean confidential_gift
}
MOTIVATION_GROUP {
varchar motivation_group_code
int ledger_id
varchar motivation_group_description
boolean group_status_active
}
MOTIVATION_DETAIL {
varchar motivation_group_code
varchar motivation_detail_code
int ledger_id
varchar motivation_detail_desc
varchar account_code
varchar cost_centre_code
decimal tax_deductible_pct
boolean receipt_eligible
boolean active
}
RECURRING_GIFT_BATCH {
int batch_id
int ledger_id
varchar batch_description
varchar batch_status
varchar currency_code
decimal exchange_rate_to_base
}
RECURRING_GIFT {
int recurring_gift_id
int batch_id
bigint donor_partner_key
varchar frequency_code
date start_donations
date end_donations
varchar sepa_mandate_reference
date sepa_mandate_date
}
RECURRING_GIFT_DETAIL {
int recurring_gift_detail_id
int recurring_gift_id
int detail_number
bigint recipient_partner_key
decimal gift_amount
decimal gift_amount_intl
varchar motivation_group_code
varchar motivation_detail_code
varchar cost_centre_code
varchar account_code
decimal tax_deductible_pct
}
GIFT_BATCH ||--o{ GIFT : "contains"
GIFT ||--o{ GIFT_DETAIL : "has details"
GIFT_DETAIL }o--|| MOTIVATION_DETAIL : "categorized by"
MOTIVATION_GROUP ||--o{ MOTIVATION_DETAIL : "groups"
RECURRING_GIFT_BATCH ||--o{ RECURRING_GIFT : "contains"
RECURRING_GIFT ||--o{ RECURRING_GIFT_DETAIL : "has details"
RECURRING_GIFT_DETAIL }o--|| MOTIVATION_DETAIL : "categorized by"
Target Table Structure Details
| Table | Purpose | Key Columns | Indexes |
|---|---|---|---|
| gift_batch | Gift batch headers with posting status and financial period assignment | batch_id (PK), ledger_id, batch_status, gl_effective_date, currency_code |
IX_gift_batch_ledger_status (ledger_id, batch_status), IX_gift_batch_period (batch_year, batch_period) |
| gift | Individual gift transactions within a batch, linked to donor partner | gift_id (PK), batch_id (FK), donor_partner_key, date_entered |
IX_gift_batch (batch_id), IX_gift_donor (donor_partner_key), IX_gift_date (date_entered) |
| gift_detail | Line items with amounts, motivation codes, tax deductibility, and recipient | gift_detail_id (PK), gift_id (FK), motivation_group_code, motivation_detail_code, tax_deductible_pct |
IX_gift_detail_gift (gift_id), IX_gift_detail_motivation (motivation_group_code, motivation_detail_code), IX_gift_detail_recipient (recipient_partner_key) |
| motivation_group | Top-level motivation categories for donation classification | motivation_group_code (PK), ledger_id (PK) |
Primary key composite index |
| motivation_detail | Specific motivation codes within groups, with account mappings and tax rules | motivation_group_code (PK/FK), motivation_detail_code (PK), ledger_id (PK), account_code, tax_deductible_pct |
Primary key composite index, IX_motivation_detail_account (account_code) |
| recurring_gift_batch | Recurring donation batch headers for automated collection | batch_id (PK), ledger_id, batch_status, currency_code |
IX_recurring_batch_ledger (ledger_id) |
| recurring_gift | Recurring donation definitions with SEPA mandate references | recurring_gift_id (PK), batch_id (FK), donor_partner_key, frequency_code, sepa_mandate_reference |
IX_recurring_gift_batch (batch_id), IX_recurring_gift_donor (donor_partner_key) |
| recurring_gift_detail | Recurring donation line items with amounts and motivation mappings | recurring_gift_detail_id (PK), recurring_gift_id (FK), motivation_group_code, motivation_detail_code |
IX_recurring_detail_gift (recurring_gift_id) |
Cross-service data access: The donor_partner_key and recipient_partner_key columns reference Partner records managed by the legacy system. These are not foreign keys in the target database — the drop-constraints slice-boundary FK policy (specified in report-plan.json) means partner validation is enforced at the application layer through the strangler-fig bridge to the legacy Partner service, not through database-level referential integrity. This enables independent deployment of the gift-processing slice without requiring Partner modernization to complete first.
For detailed schema mappings from legacy typed datasets and SQL query files to this target data model, see Section 10: Data Mapping Strategy.
4.5 Target Service Architecture
Consensus outcome: 2 services. Three independent perspectives — Domain-Driven Design, Technical Architecture, and Business Capability — all converged on a two-service decomposition. Composite weighted score: 7.8 / 10 (gate: ≥ 7.0). Full multi-perspective analysis in Appendix B.
The two target services are Gift Processing API (donation processing, bank import, recurring gifts, motivation administration, posting state machine) and Receipt Generation Service (annual tax-receipt PDFs, template registry, donor statement export).
4.5.1 Consensus Across Lenses
| Lens | Service Count | Weighted Score | Key Insight |
|---|---|---|---|
| Domain-Driven Design | 2 | 7.9 | Single bounded context (DonationManagement, confidence 0.84) splits into two aggregate clusters: Gift Lifecycle (transactional) and Receipt Issuance (document pipeline). |
| Technical Architecture | 2 | 7.7 | Year-end receipt CPU/memory spike has a fundamentally different scaling profile from year-round gift entry; deployment independence keeps template churn off the transactional core. |
| Business Capability | 2 | 7.9 | Two jobs-to-be-done with different cadences and often different humans: daily donation processing (finance ops) and year-end receipt issuance (compliance/tax). |
4.5.2 Open Questions Resolved
| Question | Consensus Resolution |
|---|---|
| Final service count | 2. All three lenses agree; no dissent. |
| Receipt Generation data access | API-only. Receipt Generation reads posted gifts via GET /api/gift-processing/v1/donors/{partnerKey}/posted-gifts. No shared database access — preserves the GiftBatch posted-state invariant at exactly one boundary. |
| Async pathway for bulk receipts | Optional / on-demand. Synchronous bulk generation with progress reporting is sufficient at pilot scale. Azure Service Bus is provisioned only if year-end runtime exceeds user tolerance or durable retry becomes necessary. |
| Bank Import as separate service? | No. Bank statement parsing (CAMT/MT940/CSV/ZIP) is a workflow that writes the GiftBatch aggregate — it stays as an internal handler inside Gift Processing API. |
| Motivation Administration as separate service? | No. Motivation codes are reference data referenced by every gift detail. A separate service would force a cross-service call on the hottest read path. Stays inside Gift Processing API as an admin sub-controller. |
4.5.3 Target Service Summary
| Service | Primary Responsibilities | Owned Tables | Scaling Profile |
|---|---|---|---|
| Gift Processing API | Gift batch CRUD & posting, bank import, recurring gifts & SEPA mandates, motivation administration, multi-currency, tax-deductibility calculation | gift_batch, gift, gift_detail, motivation_group, motivation_detail, recurring_gift_batch, recurring_gift, recurring_gift_detail (8) | Year-round steady; autoscale 2–4 instances; user-driven load |
| Receipt Generation Service | Annual tax-receipt PDF generation (single and bulk), HTML template registry, donor statement export, receipt sequence numbering, reprint with audit trail | receipt_issuance, receipt_template (2) | Year-end spike (Jan–Feb); autoscale 1–6 instances; bursty CPU/memory for PDF rendering |
4.5.4 Target Integration Pattern
Gift Processing API is the authoritative source of donation data. Receipt Generation Service is a one-way downstream consumer that reads posted gifts via REST and produces immutable PDF artifacts. There is no shared database, no shared EF Core context, and no Gift Processing dependency on Receipt Generation. Both services sit behind Azure API Management with the same authentication, observability, and strangler-fig routing surface described in Section 4.1.
For the complete multi-perspective analysis — per-lens proposals, rationale for rejecting alternatives, full per-service API contracts, sample C# interfaces, OpenAPI excerpts, message envelopes, and the implementation roadmap — see Appendix B: Service Architecture.
5. Platform Affinity Analysis
Purpose: Identifying Legacy Constraints That Should NOT Transfer
Not all source platform behavior should be transcoded 1:1 to the target. This section analyzes implementation constraints from the legacy .NET Framework 4.7 + jQuery/ES5 environment that were necessary in the original system but should be eliminated or redesigned for the modern platform.
Scope: Focused on the Finance — Gift Processing subsystem (multi-currency batch posting, recurring gifts, motivation administration, bank statement import, annual receipts).
Source: Curated from Section 6 (Technical Debt Analysis) with forward-platform-unlock framing. Every entry below explicitly cross-references the originating Section 6 concern (C1–C7), dependency row, anti-pattern note, or strength.
Note: For UI-specific per-screen transformation analysis (layout, navigation, individual component redesign), see Section 7: UI/UX Transformation Examples. Section 5.3 covers UI platform constraints; Section 7 covers UI transformation.
Every legacy platform imposes constraints on the software that runs on it. Some of those constraints encode genuine business requirements — a tax-deductibility percentage reflecting national charity law, a SEPA mandate window enforcing banking compliance, or a posted/unposted state machine protecting financial integrity. Others are pure platform artifacts: an EOL runtime that lacks types now in-box on newer frameworks, a hosting bridge that splits responsibility across three layers, or a UI toolchain whose bundler is no longer maintained. Getting the distinction right is critical to avoiding two failure modes — replicating unnecessary limitations in the modern system (wasting engineering effort reproducing constraints that serve no purpose), or accidentally breaking a business rule that was disguised as a platform constraint (producing software that silently violates business intent).
The Petra slice is unusual in that the language family is unchanged (C# → C#), but the runtime, hosting model, client framework, and toolchain all move. That makes every entry below highly concrete: each constraint either (a) goes away because the runtime is .NET 10 instead of .NET Framework 4.7, (b) goes away because the host is Azure App Service + Kestrel instead of Mono FastCGI, (c) goes away because the client is Angular 20 instead of jQuery 3.6, or (d) collapses because the storage abstraction reduces to PostgreSQL-only on the modern side. Entries are classified platform-driven (ELIMINATE), hybrid (eliminate the platform artifact, preserve the business intent), or business-driven (PRESERVE). Pure PRESERVE entries are uncommon in Section 5 by design — this section is fundamentally about unlocks; pure business preservation lives in Section 9 (Business Rules Analysis).
5.1 Capacity Constraints
5.1.1 .NET Framework 4.7 Runtime Ceiling
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Source: Runtime is not manifested in packages.config but is load-bearing for every package below it. The Gift Processing server-side codebase compiles against .NET Framework 4.7.Root Cause: .NET Framework 4.7.0 is past Microsoft mainstream support. Every server-side dependency in csharp/ThirdParty/packages.config is pinned to a version that compiles against 4.7 — including Npgsql 4.1.10 with a source-tree comment that reads <!-- pinned: 5.x requires .NET 5+ --> and a SharpZipLib 1.3.3 pinned for the same reason; PDFsharp 1.50.5147 and NPOI 2.6.0 sit on the same constraint. The runtime ceiling cascades into a library-version ceiling, which cascades into a feature ceiling: the team cannot adopt async-streaming Npgsql APIs, modern JSON source generators, or in-box System.Memory primitives because the runtime lacks them. (See Section 6.7 rows .NET Framework 4.7, System.* facade packages, SharpZipLib 1.3.3, PDFsharp 1.50.5147, NPOI 2.6.0, and the “EOL-major / Replaced by target” classification on the runtime row.)
|
.NET 10 LTS provides active support through November 2026 with security extensions beyond. The polyfill packages (System.Buffers, System.Memory, System.ValueTuple, Microsoft.Bcl.AsyncInterfaces, System.Threading.Tasks.Extensions, System.Runtime.CompilerServices.Unsafe, System.Text.Encodings.Web) are in-box and disappear from the dependency manifest. Npgsql 8.x, SharpZipLib 1.4.x, PDFsharp 6.x, NPOI 2.7.x, and MimeKit/MailKit 4.x become available; the version-ceiling cascade dissolves. Azure App Service P1v3 Linux containers expose configurable per-request limits decoupled from any in-process memory cap. |
| Legacy Behavior | Operators cannot adopt any Petra release that depends on .NET 5+ libraries. Security patches for 4.7.0 are no longer mainstream — CVEs require manual backporting or remain unpatched. The polyfill packages (roughly a third of the 19 server NuGet entries) exist solely because the runtime lacks types that live in mscorlib on modern .NET. Large bank-statement imports (CAMT v053, MT940, ZIP-bundled multi-month statements) hit AppDomain memory pressure under the FastCGI worker model and force the operator to split files manually. Bulk annual-receipt runs over 10,000+ donors are constrained by Mono GC pause behavior on long-running operations. Linux deployments rely on fastcgi-mono-server4 — itself effectively abandoned (see 5.2.2). |
The modern slice runs on .NET 10 with Kestrel + ASP.NET Core hosting on Azure App Service Linux. The polyfill packages are deleted at modernization time, shrinking the dependency surface by approximately 30%. Active-support security patches are delivered automatically through the Microsoft update channel and the package ecosystem. Bank-file parsing streams end-to-end via Stream + async IAsyncEnumerable<BankStatementLine>; P1v3 autoscale rules (2–4 instances baseline; 1–6 for Receipt Generation in Jan–Feb) absorb peak load without per-request file-size negotiation. Adoption of Npgsql 8’s async-streaming APIs is gated only by code authoring, not by runtime version. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The .NET Framework 4.7 runtime ceiling is a pure platform limitation with no business justification. Rationale: No business rule requires .NET Framework 4.7. The runtime was current when the legacy implementation was authored; it has since fallen out of mainstream support. The cascade of pinned dependencies is entirely a consequence of the framework version, not of business requirements. The pinning comments in packages.config are explicit acknowledgments that the team would prefer newer libraries if the runtime allowed it.Implementation: Adopt .NET 10 LTS as the modernization’s anchoring decision (per Section 4). Delete the polyfill packages from the new .csproj. Upgrade Npgsql, SharpZipLib, PDFsharp, NPOI, MimeKit, MailKit, and Portable.BouncyCastle to their .NET 10-compatible major versions. Same-language modernization (C# → C#) means the upgrade is a build-system + framework version change, not a code rewrite.
|
|
5.2 Processing Model Constraints
5.2.1 ASP.NET Web Services (.asmx) Synchronous SOAP/RPC Pipeline
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Concho Entities: TGiftTransactionWebConnector and the family of *.asmx endpoints expose the gift-processing surface as [WebMethod]-decorated methods inside ASP.NET Web Services; the RpcEndpointCommunication Concho pattern (confidence 0.90) tags the wire shape. The wire format is a SOAP envelope wrapping JSON-RPC payloads with binary-serialized SortedList parameters — the framing is cosmetic, but the per-method ceremony is not.Root Cause: ASMX uses the classic ASP.NET 1.1-era synchronous request pipeline ( HttpHandler → method dispatch → response) with no native async, no streaming, and no graceful cancellation propagation. Per-endpoint policy attachment (auth, rate limit, output caching) is not available; discoverability is limited to WSDL; payload contracts are declared via attributes only on SerializableSortedList-style argument bags; and there is no Core successor — System.Web is a .NET-Framework-only namespace. (See Section 6.5 source-language anti-pattern entry “.asmx is not ‘hard SOAP’” and the C1-retirement framing.)
|
ASP.NET Core Minimal APIs (or Controllers) on .NET 10 with Kestrel hosting expose the same gift-processing methods over REST. Swashbuckle auto-generates the OpenAPI document from method signatures; per-endpoint policy attachment (auth, rate limit, output caching) composes through middleware. The async/await chain reaches into the DB driver and out to System.Text.Json end-to-end. No System.Web, no SOAP envelope, no SortedList argument bags. |
| Legacy Behavior | A user starting a 30-second bulk SEPA recurring-gift posting cannot navigate away or cancel the request; the worker thread is held for the full duration. Two concurrent bulk runs from different ledgers contend for worker-thread capacity even though the operations are independent. Adding a new POST /batches/{batchId}/post-style operation requires authoring a new [WebMethod] + a SortedList parameter bag + a separate documentation source for partners; OpenAPI tooling does not apply. Clients (the jQuery front-end and any integration partners) must conform to the SOAP framing then introspect the response shape from a WSDL that does not survive payload evolution gracefully. |
Adding a new endpoint is a single app.MapPost("/api/gift-processing/v1/batches/{batchId}/post", …) call. The OpenAPI document updates automatically; clients regenerate typed contracts with NSwag / kiota. Long-running operations either run async with cancellation tokens (the user can navigate away and poll status) or move to Azure Service Bus when provisioned, freeing the API thread immediately. The same posted/unposted state machine (the strangler routing key from Section 4) is expressed as a routable REST surface, which is the precondition for the Azure API Management traffic shift. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The .asmx RPC framing + synchronous pipeline is a pure platform limitation with no business justification. Rationale: No business rule requires SOAP-style framing for gift-processing requests. The framing is an accident of the era when System.Web was the only ASP.NET; the wire format inside the envelope is already JSON. The modernization treats this as HTTP-RPC → REST per the Section 6.5 anti-pattern guidance, not a SOAP migration. The RPC contracts are business; the transport is platform.Implementation: Recreate the TGiftTransactionWebConnector surface as ASP.NET Core Minimal API endpoints under /api/gift-processing/v1/ per the Appendix B catalog. Swashbuckle generates the OpenAPI document. Azure API Management routes traffic between the legacy ASMX endpoints and the new REST endpoints during the strangler-fig window. FluentValidation replaces the in-process TVerificationResult chain at the boundary; RFC 7807 ProblemDetails carries failure responses. Once the cutover is complete, the entire System.Web dependency disappears from the slice. Section 8 (Code Translation) demonstrates the LoadGiftBatch → MapGet rewrite end-to-end.
|
|
5.2.2 Mono FastCGI Hosting Model and [ThreadStatic] Isolation
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Concho Entities: Hosting-runtime technology subject; fastcgi-mono-server4 process configuration on the legacy Linux deployment; the Concho ThreadStaticIsolationConstraint pattern (confidence 0.90) tags the per-thread state-isolation strategy that couples to the FastCGI worker shape.Root Cause: Mono FastCGI is the historic way to run .NET-Framework-targeted ASP.NET on Linux. The hosting model splits responsibility across three layers (front-end nginx/Apache → FastCGI bridge → Mono runtime), each with its own lifecycle, process model, and configuration surface. Connection management between FastCGI and Mono is request-affinitized; the Mono project itself has deprioritized the FastCGI server — the component is community-maintained at best. Compounding the problem, server components annotated with [ThreadStatic] for per-thread isolation (workflow state, session-scoped lookups) couple the application’s state-isolation strategy to the FastCGI worker shape, prevent async/await across the affected fields, and break under any cooperative scheduling model. (See Section 6.7 row Mono FastCGI classified EOL-project / Removed by target and the Section 6.5 anti-pattern entry “Mono FastCGI hosting”.)
|
Kestrel is the ASP.NET Core in-process HTTP server, running directly inside the .NET 10 process under Azure App Service Linux. There is no separate FastCGI bridge, no Mono runtime, no Apache/nginx requirement on the App Service plan. The hosting model is one layer, one process, one configuration surface. Async-await-driven I/O has no thread affinity; per-request state lives in HttpContext.Items or scoped DI services (IServiceScope) where it stays correct across await points without the FastCGI worker shape leaking in. |
| Legacy Behavior | Operators must keep three concerns aligned: the Apache/nginx FastCGI module configuration, the fastcgi-mono-server4 arguments and worker counts, and the Mono runtime version. Worker recycling, connection-pool resets, and graceful shutdown are managed across the boundary; an SLA-impacting deploy can require coordinated reloads in two places. The team has historically avoided async code paths on the server because [ThreadStatic] data does not flow across await — forcing synchronous-only handlers for any code path that touches per-request state, which is part of why long-running operations (annual receipts, bulk imports) appear single-threaded to operators. During year-end load peaks the team currently restarts the Mono process daily to clear accumulated session memory. |
Operators manage one App Service plan with slot-based blue/green deploys (Section 4). Health probes (/health/ready, /health/live, /health/startup) drive slot-swap behavior. No FastCGI bridge to coordinate; no Mono runtime to track. All Gift Processing API endpoints become async end-to-end; per-request state lives in scoped DI; background workflows (bulk receipts) become BackgroundService instances on the Receipt Generation Service with Service Bus session-ordered messaging. Daily process restarts are no longer needed. Kestrel’s connection pool, request scheduler, and graceful-shutdown semantics are part of the .NET 10 LTS support contract. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Mono FastCGI plus the [ThreadStatic] isolation pattern is a pure platform artifact with no business justification.Rationale: No business rule requires Mono, FastCGI, or any specific Linux web-server bridge. The state-isolation requirement (one request’s state must not bleed into another’s) is preserved — only the mechanism changes from thread-affine fields to scoped DI. The hosting model was chosen because .NET Framework had no in-process HTTP server on Linux at the time. .NET 10 with Kestrel is the modern equivalent and is a first-class supported platform on Azure App Service Linux. Implementation: Build the Gift Processing API as a standard ASP.NET Core 10 application targeting linux-x64. Deploy as an Azure App Service plan (P1v3 Linux) per Section 4. Convert every [ThreadStatic] field to a scoped service registration. Make all gift-processing handlers async. Remove all FastCGI / Mono configuration from the modernization slice. Apache/nginx remain in front of the legacy stack during coexistence, but the modern slice does not need them — App Service’s front-end provides TLS and HTTP/2 termination.
|
|
5.2.3 Multi-Currency Engine Observability Gap
⚠️ SPECIAL CASE: This constraint is BOTH platform-driven AND business-driven
The legacy Finance Operations Engine combines (a) correct multi-currency / multi-country tax-deductibility math that must survive verbatim, and (b) a hosting model with zero telemetry that hides bottlenecks. The math is business; the opacity is platform. The modernization separates them.
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Concho Entity: Finance Operations Engine (Application Layer.Finance Management — 49,362 LOC, the largest single architectural element at 17.2% of the analyzed source); the MultiCurrencyAmountHandling pattern (confidence 0.90) tags the per-currency rounding and tax-deductibility code paths.Root Cause: Concho’s architectural assessment flags the engine at medium severity not because the multi-currency or tax math is wrong, but because automated batch operations (annual receipts, recurring-gift processing) have no observability today. Latency, throughput, per-currency calculation timing, and per-batch posting cost are invisible until they show up as user-reported slowdowns. The legacy hosting stack (.NET Framework 4.7 + Mono FastCGI + custom session logging via TLogging writing to a single rolling file with no structured fields) provides no built-in tracing, no metrics surface, and no correlation IDs between front-end requests and back-end batch invocations. The 70% concentration of HIGH-complexity workflows in the engine means any latency regression is hard to localize. (See Section 6 C2 — Finance Operations Engine classified Mitigated by target.)
|
The same calculations cross the boundary verbatim (Section 8 walks through one per-currency rounding example bit-exact). What the target adds is observability: OpenTelemetry SDK on .NET 10 emits traces and metrics to Azure Application Insights via OTLP. Serilog with structured JSON logging propagates correlation IDs end-to-end. Per-batch posting latency, per-currency conversion timing, and per-motivation-detail throughput become first-class metrics with dashboards and alerts; auto-instrumentation covers ASP.NET Core, EF Core, and outbound HttpClient calls. |
| Legacy Behavior |
Platform Constraint: When a finance operator reports “the November multi-currency batch was slow” there is no per-stage attribution available; the only debugging path is to re-run the batch under a profiler attached to the Mono process. There is no built-in correlation between a user action on the Gift Batches screen and the downstream multi-currency calculations. Business Requirement: The math itself — multi-currency conversion against per-period ledger rates, percentage-based tax-deductibility per motivation detail, recurring-gift schedule expansion, SEPA mandate validity windows — is correct domain logic. Compliance audits (charity law, banking regulators) depend on the math producing exactly the same numbers it does today. |
Platform Unlock: OpenTelemetry instrumentation captures latency, throughput, and per-stage timing for every posting cycle. Application Insights dashboards show p50/p95 by batch size, by currency count, by donor count. Custom Activity sources wrap the per-currency math; custom metrics (gift_batch.calculation.duration_ms by currency code) ship with 100% sampling and 90-day retention. The bottleneck risk is not eliminated — the calculation is still O(batch × currencies) — it is made visible, which is the precondition for fixing it when it materializes.Business Preserved: The C# code that computes per-currency amounts, tax-deductibility percentages, and recurring-gift expansions ports forward as-is. Same rounding rules, same conversion-rate lookups, same audit-trail fields. Section 8 includes a side-by-side that exercises the rounding contract. |
| Recommendation |
⚖️ HYBRID APPROACH — Eliminate the platform-imposed opacity; preserve the multi-currency / tax-deductibility business math verbatim. Rationale: The engine’s combination of business correctness and platform opacity is a textbook hybrid case. Section 6.4 lists the Business Rules Validation Engine and the multi-database abstraction as architectural strengths to preserve; this entry is consistent with that framing. Removing the math would break compliance; removing the opacity removes risk without changing behavior. Implementation: Port the Finance Operations Engine to .NET 10. Wrap every public posting / receipt / recurring entry point with OpenTelemetry activity sources. Emit OpenTelemetry metrics for gifts-processed-per-second and posting-batch-latency. Serilog enrichers attach {LedgerId, BatchId, Currency} properties to every log entry inside the engine. Wire FluentValidation to replace the in-process TVerificationResult validation, returning RFC 7807 ProblemDetails (preserving validation semantics; replacing the framework). Section 8 examples include the per-currency rounding contract; Section 9 (Business Rules) catalogs the preserved math.
|
|
5.3 User Interface Constraints
5.3.1 jQuery / ES5 Web Client Ceiling
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Concho Entity: Web Client Interface (Presentation Layer.Web Client — 6,682 LOC, the single high-severity concern in Concho’s assessment, LLM confidence 0.85). Source: js-client/package.json — jquery ^3.6.0, browserify ^16.5.2, uglify-js ^3.16.3, browserify-css ^0.15.0; ES5 JavaScript throughout the client tree.Root Cause: The front end is jQuery 3.6 + vanilla ES5 with browserify as the module bundler and uglify-js for minification. The stack was reasonable circa 2016–2018 but constrains the team on three axes in 2026: (1) no component model — reusable UI is copy/paste at the template level rather than encapsulated components; (2) no reactive change detection — screens that touch many fields (Annual Receipts, Gift Batch posting) hand-wire DOM updates; (3) developer recruitment friction — jQuery experience is increasingly rare in junior hires. The bundler family is collectively EOL-project (browserify, browserify-css, uglify-js); the HTTP client (axios 0.21.4) carries documented CVEs (CVE-2021-3749, CVE-2023-45857). (See Section 6 C1 — Web Client Interface jQuery / ES5; Section 6.7 rows jquery, browserify, browserify-css, uglify-js, axios.)
|
Angular 20 with standalone components, signals-based reactivity, reactive forms, and Angular Material accessibility primitives. The Angular CLI replaces the entire browserify-era toolchain (esbuild / Vite under the hood) with one integrated build, dev server with hot module replacement, source maps wired by default, and route-level code splitting available out of the box. Angular HttpClient replaces axios. The build emits a tree-shaken, code-split bundle; routing is via the Angular Router; state is via signals or services. Bundle delivery is via Azure App Service static files (or Azure Static Web Apps if the team chooses to split the deployment). |
| Legacy Behavior | The five in-scope screens — Gift Batches, Recurring Gifts, Motivations, Bank Import, Annual Receipts — are jQuery + ES5 + Bootstrap 4.6 templates with manual DOM mutation and ad-hoc state management. The Annual Receipts screen, which touches many fields per donor, re-renders the entire donor list on every filter change because jQuery has no way to express “only the rows whose visibility flipped”. Bulk recurring-gift edits sometimes leave the screen in an inconsistent state if two AJAX calls return out of order; the user has to refresh to recover. Front-end developers rebuild the bundle on every change (no HMR); the rebuild takes ~10–20 seconds on a small change. The axios 0.21.4 HTTP client carries CVE-2021-3749 and CVE-2023-45857 — not exploitable in every deployment but a real audit-finding risk. |
The five screens are rebuilt as Angular standalone components consuming the new REST endpoints. Reactive forms handle validation declaratively; signals propagate state changes without manual subscription wiring. Filtering re-renders only the rows whose signal-derived visibility flipped. Multiple in-flight HTTP calls compose cleanly through RxJS operators (or signal-based async pipes), so out-of-order completion does not corrupt the visible state. Edit-save-see in <1 second via the Angular CLI dev server’s HMR. The HTTP client is Angular’s built-in HttpClient, eliminating the axios CVE surface entirely. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The jQuery / ES5 + browserify bundler stack is a pure platform limitation with no business justification. Rationale: No business rule requires jQuery, browserify, uglify-js, or pre-1.0 axios. The choice of stack was era-appropriate; in 2026 each component is either EOL or carries a known CVE. The modernization is a wholesale rewrite per the Section 6.5 framing (“AngularJS framing does not apply” — Petra is jQuery, not AngularJS, so the rewrite is years lighter than an ngUpgrade-based hybrid would be).Implementation: Rebuild all five gift-processing screens in Angular 20 as standalone components per Section 7. Replace axios with Angular HttpClient. Replace browserify / uglify-js / browserify-css with the Angular CLI build pipeline ( ng new gift-processing-spa generates the toolchain). The legacy jQuery client remains in production for non-Gift-Processing surfaces (Partner, GL, Reporting) during the strangler-fig window.
|
|
5.3.2 Bootstrap 4 + popper.js 1.x + FontAwesome 5 EOL UI Library Stack
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Source: js-client/package.json — bootstrap ^4.6.1, popper.js ^1.16.1, @fortawesome/fontawesome-free ^5.15.4. Modal/tooltip/popover primitives are wired via Bootstrap data attributes + jQuery initializers; FontAwesome 5 icon set is referenced through CSS classes across templates.Root Cause: Bootstrap 4 reached EOL January 2023; popper.js 1.x is no longer maintained (the project renamed to @popperjs/core at 2.x and rewrote the API); FontAwesome 5.15.4 is two majors behind 6.x. Layout grid, responsive breakpoints, modal management, and tooltip positioning all anchor to this stack. None of it is business intent — all of it is browser-era UI tooling that has since been superseded. Tooltip positioning misbehaves at narrow viewport widths because popper.js 1.x lacks the modern viewport-collision logic the 2.x rewrite introduced. (See Section 6.7 dependency rows for bootstrap 4.6, popper.js 1.16, @fortawesome/fontawesome-free 5.15 — all EOL-major / Replaced or Removed by target.)
|
Angular Material owns the layout grid, dialog (modal), tooltip, menu, snackbar, and form-field primitives with built-in focus management, ARIA wiring, and keyboard-trap behavior. Angular CDK provides lower-level overlay/portal/drag-drop primitives that replace popper.js; overlay positioning is correct across viewport sizes. Material icons are first-class and theme-aware; FontAwesome is not required. The whole UI inherits a consistent theming surface (Angular Material themes) instead of bolted-together Bootstrap component CSS. |
| Legacy Behavior | Modal-based donor-batch editing relies on jQuery + Bootstrap data attributes; accessibility is hand-wired and incomplete (focus traps, ARIA labels). Icons referenced by name strings can drift silently between FontAwesome majors during upgrades; translation strings live in i18next 10 JSON files that diverge gradually from the server-side resource catalogs (covered in 5.3.3). None of the upgrade paths is in-place — each requires markup changes throughout the client codebase. | Components are typed via Angular Material; tokens are validated at compile time; icons are typed enums. Drift between layers is eliminated mechanically rather than reviewed manually. Section 7 demonstrates the Bootstrap-modal → Material-dialog transformation on the Gift Batches screen. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The Bootstrap 4 / popper.js 1.x / FontAwesome 5 cluster is a pure platform limitation with no business justification. Rationale: No business workflow depends on a Bootstrap component class name or a FontAwesome icon family. The visual surface is reimplemented in Angular Material so the team gets accessibility, theming, and modern overlay positioning for free. Implementation: Replace Bootstrap 4 with Angular Material (or Angular + ng-bootstrap if the team prefers the Bootstrap component look-and-feel — flagged as an open question in the tech-debt handoff). Angular CDK overlays replace popper.js wholesale. Material icons replace FontAwesome. The replacement is whole-stack: removing Bootstrap also removes the Bootstrap jQuery init code, which removes a chunk of the legacy js-client/ footprint.
|
|
5.3.3 i18n Toolchain Lock-In (i18next 10 + GNU Gettext PO)
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Source: js-client/package.json — i18next ^10.6.0, i18next-browser-languagedetector ^2.2.4, i18next-xhr-backend ^1.5.1. Server-side uses GNU Gettext PO files via a custom GettextResourceManager (per source profile legacyIdiomInventory).Root Cause: The translation toolchain bridges two unrelated ecosystems — i18next 10.x on the client (long EOL; current 23.x is a different framework, with substantial API rewrites between) and Gettext PO files on the server. Each ecosystem has its own message catalog format, its own key/namespace convention, and its own runtime loader. Translators maintain two catalogs; developers maintain two adapters; the build pipeline must keep the PO files and the JSON message bundles in sync. The i18next-xhr-backend 1.5.1 adapter loads JSON over XHR at runtime — a pattern newer i18next versions deprecated. (See Section 6.7 rows i18next and adapters classified EOL-major / Replaced by target; source profile legacyIdiomInventory entry “GNU Gettext PO files for i18n”.)
|
Angular i18n on the client (the official localization framework, with build-time compilation and signal-based locale switching) plus ASP.NET Core IStringLocalizer + .resx resources on the server. Both share Microsoft / Angular tooling. Translators maintain a single canonical source per locale; XLIFF interchange is the standard format on both sides. |
| Legacy Behavior | The Annual Receipts screen, which renders donor-facing PDF templates with locale-sensitive currency formatting and salutations, has to look up messages in two catalogs depending on whether the message is rendered server-side (PO) or client-side (i18next JSON). Adding a new locale is a catalog clone on both sides plus an adapter update on each. Translation strings drift gradually between client and server because nothing enforces parity at build time. | Angular i18n compiles translations into the build output; server-side .resx files are reloaded through the ASP.NET Core localization middleware. XLIFF interchange means translators receive one file per locale per side and round-trip through standard tooling. The Annual Receipts PDF template can use the same server-side message catalog as the rest of the receipt-generation flow. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The dual i18next-PO toolchain is a pure platform limitation with no business justification. Rationale: No business rule requires i18next 10.x, GNU Gettext, or any specific message-catalog format. The dual-catalog pattern exists because the legacy era picked the popular client and server tools independently; modernization is the opportunity to unify on standard Microsoft / Angular tooling.Implementation: Replace i18next 10.x with Angular i18n. Replace GNU Gettext PO files with ASP.NET Core IStringLocalizer backed by .resx resources. Establish XLIFF as the translator interchange format on both sides. Existing translations are converted once during the slice cutover.
|
|
5.4 Data Type Constraints
5.4.1 Code-Generated Typed Dataset / SortedList Parameter Rigidity
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Source: The data access layer is built on *.Generated.cs files emitted by TDataDefinitionParser / TDataDefinitionStore from db/petra.xml (master schema, 24,925 LOC). Tables, columns, and FK relationships are generated as System.Data.DataSet + DataTable + DataRow classes with strongly-typed accessors. Binary-serialized SortedList parameters carry method inputs over the .asmx wire.Root Cause: The typed-dataset pattern was the .NET Framework 1.1–2.0 era’s answer to ORM-style typed access. Each schema change requires re-running NAnt + the code generator, which overwrites the entire generated layer. Editing the generated files is futile (the next NAnt run wipes the edits); editing the XML schema requires a full regeneration cycle. The pattern leaks DataRow-style nullability into business logic (every column is nullable at the API level), and async/streaming access is impossible because DataSet is a fully-buffered in-memory structure. The binary-serialized SortedList parameter shape compounds this: every web-method input is a string-keyed map of object values, and type fidelity depends on convention rather than the type system. NAnt’s last release was 2012; it is a batch-only, file-driven build tool with no incremental support, no parallelism awareness, and no integration with modern CI/CD. (See Section 6.5 anti-pattern entries “Code-gen-from-XML is the source of truth” and “.asmx is not hard SOAP”; source profile legacyIdiomInventory entry “Generated typed datasets from XML schema”.)
|
Entity Framework Core 8 with Code-First entity classes and EF Core Migrations. The 8 gift tables (per Section 4.4) become POCO entity classes with C# 8+ nullable reference types — required columns are non-nullable in the entity surface, matching the database DDL. Schema evolution is a migration generated by dotnet ef migrations add; rollback is dotnet ef database update. Async access is the default (await dbContext.GiftBatches.ToListAsync()). System.Text.Json source generation provides typed DTOs for API request/response shapes; the contract is auto-discoverable via OpenAPI. The build itself uses dotnet build + MSBuild incremental compilation; GitHub Actions handles CI/CD with caching and parallelism. |
| Legacy Behavior | Adding a column to a_gift_detail requires editing db/petra.xml, running the NAnt-driven code generator, recompiling, and updating every business-logic call site that touches the regenerated DataRow accessor. Required-vs-optional column semantics live in the XML metadata, not in the C# type system — every accessor returns nullable, so business logic is littered with row.IsXxxNull() checks. The fully-buffered DataSet shape means batch-posting operations can’t stream large gift batches; the entire batch loads into memory. A developer adding a field also has to find every SortedList call site that passes that field and add it by string-key — there is no compile-time guard against typos. Mismatches surface only as runtime exceptions. |
Adding a column is a property addition on the entity + a generated migration. Required-vs-optional is encoded in the C# nullable annotation, surfaced by the compiler. Streaming queries are first-class (IAsyncEnumerable<Gift>). Every call site that produces or consumes the entity gets a compile-time error if the field is missing or its type is wrong. OpenAPI re-publishes the new shape on the next build. The single-source-of-truth property from petra.xml is preserved during coexistence by a one-time generator that emits EF Core entities from a curated subset of the master schema (Section 6.4 architectural strength); once the cutover is complete, EF Core Migrations become the source of truth. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The typed-dataset code-generation pipeline + SortedList parameter shapes is a pure platform limitation with no business justification. Rationale: No business rule requires DataSet / DataTable / DataRow or NAnt-driven generation. The pattern is an artifact of pre-LINQ, pre-ORM .NET. The single-source-of-truth property (the architectural strength called out in Section 6.4) is preserved at a different level — EF Core Migrations are the new source of truth once the legacy XML schema is exported for the 8 gift tables.Implementation: One-time generator emits EF Core entity classes for the 8 gift tables from a curated subset of db/petra.xml covering Gift Processing. After that baseline commit the generator is retired; further schema evolution is via dotnet ef migrations add. The legacy generation pipeline keeps running for the out-of-scope subsystems during strangler-fig coexistence. All gift-processing business logic moves to DbContext + LINQ async queries with non-nullable entity properties; binary SortedList parameter bags become typed DTOs with source-generated System.Text.Json serialization. NUnit fixtures port one-to-one to xUnit + WebApplicationFactory + Testcontainers PostgreSQL.
|
|
5.4.2 Newtonsoft.Json Reflection-Based Serialization Defaults
| Aspect | Legacy System (.NET Framework 4.7 + jQuery) | Target System (.NET 10 + Angular 20 on Azure App Service) |
|---|---|---|
| Discovery |
Source: csharp/ThirdParty/packages.config — <package id="Newtonsoft.Json" version="13.0.2" />. The ASMX-era serialization defaults to Newtonsoft for the JSON payload inside the SOAP envelope; SortedList-style parameter bags are serialized via Newtonsoft converters.Root Cause: Newtonsoft.Json 13.x is still maintained but is no longer Microsoft’s recommended JSON library for new ASP.NET Core code. It serializes via runtime reflection over property descriptors — allocation-heavy and slow under JIT cold-start. The legacy serialization defaults (PascalCase property names, lenient type coercion, dictionary-key handling that differs from System.Text.Json) are baked into the wire contract between the jQuery client and the ASMX surface. Polymorphic serialization uses Newtonsoft’s $type convention, which has no direct equivalent in System.Text.Json defaults. Source generation is unavailable on .NET Framework 4.7. (See Section 6.7 row Newtonsoft.Json classified outdated (still maintained) / Replaced by target; Section 6 Open Question on System.Text.Json behavioral diffs.)
|
System.Text.Json with source-generated serializers (JsonSerializerContext) on .NET 10. CamelCase property naming policy by default (matching JSON-API conventions). Polymorphic deserialization handled via [JsonDerivedType] attributes with explicit discriminator values rather than implicit type tagging. Performance is materially better (no reflection at hot paths) and the dependency is in-box. |
| Legacy Behavior | The jQuery client posts JSON payloads with PascalCase property names; Newtonsoft’s defaults accept them. Decimal precision in multi-currency amounts is preserved by Newtonsoft’s permissive number handling. Date fields are serialized as Microsoft’s legacy "/Date(1234567890)/" format in some endpoints — a Newtonsoft-era convention. Serialization cost is invisible to the team and surfaces as “the server is slow” rather than “serialization is slow” — aggregated across bulk operations (annual receipts, large bank-import dispatches) it becomes a measurable share of CPU. |
The Angular client and the ASP.NET Core endpoints communicate via standard ISO 8601 dates and camelCase property names. System.Text.Json source-generated converters provide compile-time validation of the serialization contract; runtime allocation and reflection both drop materially. The OpenAPI document (Swashbuckle) declares the contract explicitly; client regeneration via NSwag/kiota produces typed contracts. Decimal precision is preserved via the in-box JsonConverter<decimal> with explicit handling. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — The Newtonsoft.Json defaults + Microsoft date-tick format are platform artifacts with no business justification. Rationale: No business rule requires Newtonsoft.Json, PascalCase JSON payloads, or the "/Date(.)/" format. The contract was era-driven. Microsoft’s explicit recommendation for new ASP.NET Core code is System.Text.Json; the slice is new code by definition. Behavioral compatibility is a per-call review, not a blocker.Implementation: Configure System.Text.Json with source generation per Section 4.3.1. Standardize on camelCase, ISO 8601 dates, and explicit polymorphism via [JsonDerivedType]. Section 8 (Code Translation) includes a side-by-side that exercises the behavioral diff (Newtonsoft permissive parsing vs. System.Text.Json strict mode), flagged in Section 6 Open Question. The Angular client consumes the standardized contract from day one; the legacy jQuery client continues to use the legacy ASMX surface during coexistence.
|
|
Platform vs Business Driver Analysis
| Constraint | Driver | Decision | Rationale |
|---|---|---|---|
| .NET Framework 4.7 runtime ceiling (5.1.1) | Platform (runtime EOL) | ELIMINATE | No business reason to remain on a runtime past mainstream support; polyfills + library pins all cascade from it |
| ASP.NET .asmx synchronous SOAP/RPC pipeline (5.2.1) | Platform (framework era, no Core successor) | ELIMINATE | No business reason for SOAP framing or thread-blocking handler dispatch around JSON payloads |
Mono FastCGI hosting + [ThreadStatic] isolation (5.2.2) |
Platform (legacy Linux .NET bridge + thread-affine state) | ELIMINATE | No business reason for a three-layer hosting bridge or thread-affine state isolation |
| Multi-currency engine observability gap (5.2.3) | Hybrid (platform telemetry gap + business math) | HYBRID | Eliminate observability gap; preserve compliance-bound math verbatim |
| jQuery / ES5 web client + browserify toolchain (5.3.1) | Platform (framework era + EOL components) | ELIMINATE | No business reason for jQuery, browserify, uglify-js, or pre-1.0 axios |
| Bootstrap 4 + popper.js 1.x + FontAwesome 5 (5.3.2) | Platform (EOL UI library cluster) | ELIMINATE | No business reason for Bootstrap 4 / popper 1 / FontAwesome 5 specifically |
| i18n toolchain lock-in (5.3.3) | Platform (dual-catalog adapter) | ELIMINATE | No business reason for the i18next 10.x + GNU Gettext split |
| Typed-dataset + SortedList code-gen rigidity (5.4.1) | Platform (pre-LINQ .NET pattern + NAnt build cycle) | ELIMINATE | No business reason for DataSet/DataRow; single-source-of-truth preserved via EF Core Migrations |
| Newtonsoft.Json defaults (5.4.2) | Platform (legacy serialization era) | ELIMINATE | No business reason for PascalCase JSON / /Date()/ ticks; reflection-based serializer is allocation-heavy |
Summary: Platform Affinity Decisions
| Constraint | Category | Driver | Decision | Modern Equivalent |
|---|---|---|---|---|
| .NET Framework 4.7 runtime ceiling | 5.1 Capacity | Platform | ✅ ELIMINATE | .NET 10 LTS with in-box System.* types and modern library versions |
| ASP.NET .asmx synchronous SOAP/RPC pipeline | 5.2 Processing | Platform | ✅ ELIMINATE | ASP.NET Core 10 Minimal APIs with Swashbuckle / OpenAPI, async end-to-end |
Mono FastCGI + [ThreadStatic] isolation |
5.2 Processing | Platform | ✅ ELIMINATE | Kestrel + Azure App Service Linux (P1v3) with scoped DI, slot-based blue/green |
| Multi-currency engine observability gap | 5.2 Processing | Hybrid | ⚖️ HYBRID | OpenTelemetry + Application Insights + Serilog (preserved engine math) |
| jQuery / ES5 web client + browserify toolchain | 5.3 UI | Platform | ✅ ELIMINATE | Angular 20 standalone components + HttpClient + Angular CLI / esbuild |
| Bootstrap 4 + popper.js 1.x + FontAwesome 5 | 5.3 UI | Platform | ✅ ELIMINATE | Angular Material + Angular CDK overlays + Material icons |
| i18n toolchain lock-in | 5.3 UI | Platform | ✅ ELIMINATE | Angular i18n + ASP.NET Core IStringLocalizer + .resx via XLIFF |
| Typed-dataset + SortedList code-gen rigidity | 5.4 Data Type | Platform | ✅ ELIMINATE | EF Core 10 Code-First entities + Migrations + typed DTOs (single-source-of-truth preserved) |
| Newtonsoft.Json defaults | 5.4 Data Type | Platform | ✅ ELIMINATE | System.Text.Json source-generated with camelCase + ISO 8601 |
Total constraints surveyed: 9 across 4 categories (1 Capacity, 3 Processing, 3 UI, 2 Data Type).
Decision distribution: 8 ELIMINATE, 1 HYBRID, 0 PRESERVE.
Out-of-scope concerns not curated here: Section 6 entries C3 (XML Report Template Definitions) and C4 (Partner Management Operations) are real architectural debt but live outside the Gift Processing modernization slice. They remain in Section 6.6 and do not surface in Section 5 per the agent contract.
What this means for the modernization: 100% of platform-driven limitations analyzed in this section are either fully retired (8 entries) or partially retired with business logic preserved (1 entry — the Finance Operations Engine multi-currency math) by the target .NET 10 + Angular 20 on Azure App Service architecture. The absence of any PRESERVE entries confirms that the constraints surfaced here are genuinely platform artifacts — none turned out to be business requirements in disguise. Business-driven concerns (multi-currency math, tax-deductibility percentages, SEPA mandate windows, posted/unposted state machine semantics) are cataloged separately in Section 9 (Business Rules Analysis) where they belong.
6. Technical Debt Analysis
packages.config and package.json. Highest severity: a single high-rated concern — the jQuery/ES5 web client (retired by the Angular 20 target). Of the 7 architectural concerns: 3 retired by the target, 2 mitigated, 0 inherited, 2 out-of-scope (outside the Gift Processing slice).
6.1 Methodology
This section presents a structured analysis of technical debt in the Petra (OpenPetra) codebase, drawing on two distinct evidence sources. Architectural concerns (Sections 6.2–6.6) are derived from Concho’s architectural assessment (get_architecture_insight), which evaluates the system’s 12 major and secondary architectural elements across 8 root layers and identifies severity-ranked areas of concern with element-level scoping. Outdated dependencies (Section 6.7) are derived from direct inspection of the dependency manifest files Concho catalogs in the source — the server-side csharp/ThirdParty/packages.config (NuGet, 19 packages) and the front-end js-client/package.json (npm, 13 packages, including dev/build tooling).
The architectural concerns in 6.2 are each classified against the proposed target architecture (.NET 10 + Angular 20 on Azure App Service with Azure Database for PostgreSQL — see Section 4) using four buckets: retired by target, mitigated by target, inherited, and out of scope. Section 6.7 uses a separate two-axis framework appropriate for library-level debt (lifecycle status × target action) because a per-version catalog is the right artifact for that category of debt. Both classification frameworks are intended to give stakeholders a defensible answer to the question “what specifically is broken today, and what does the modernization fix?”
This section is not a code-quality lint report, a comprehensive security audit, or a bug list. 6.2–6.6 are architectural-level debt — patterns and design choices — while 6.7 is library-level debt — specific package versions with known EOL dates, migration paths, or modern equivalents. The two categories deserve separate framings because they reach different stakeholders.
6.2 Severity-Ranked Architectural Concerns
Concho’s architectural assessment (LLM confidence: 0.85) identified 7 areas of concern across the Petra codebase. Each concern is classified against the target architecture defined in Section 4. Highest severity first.
| Concern | Architectural Element | Severity | Classification |
|---|---|---|---|
| jQuery / vanilla ES5 web client limits modern browser capabilities and developer productivity | Web Client Interface (Presentation Layer.Web Client — 6,682 LOC) | High | Retired by target |
| Complex multi-currency calculations and automated workflows may create performance bottlenecks under high transaction volumes | Finance Operations Engine (Application Layer.Finance Management — 49,362 LOC) | Medium | Mitigated by target |
| XML Report Template Definitions contain over 13% of the codebase in template files, creating maintenance overhead and potential consistency issues | XML Report Template Definitions (Presentation Layer.Report Templates — 39,992 LOC) | Medium | Out of scope |
Partner Management Operations coordinates extensive CRUD through TPartnerEditWebConnector, potentially creating transaction-management complexity |
Partner Management Operations (Application Layer.Partner Management — 28,298 LOC) | Medium | Out of scope |
TPetraPrincipal session-based authentication lacks modern security patterns such as OAuth or JWT tokens |
Security Authorization Framework (Infrastructure.Security Framework — 6,685 LOC) | Medium | Retired by target |
Common Utilities Framework may create tight coupling across modules through shared ArrayList and Utilities classes |
Common Utilities Framework (Cross-Cutting Concerns.Common Utilities — 8,083 LOC) | Low | Mitigated by target |
| Development Tools Suite (TinyWebServer + ad-hoc utilities) duplicates functionality available in modern development environments | Development Tools Suite (Build & Deployment.Development Tools — 6,575 LOC) | Low | Retired by target |
Severity is Concho’s assessment, not re-weighted by this analysis. Classification reflects how the resolved target architecture (Section 4) handles each concern.
6.3 Detailed Concern Narratives
jQuery / ES5 Web Client (High — Retired by Target)
The Web Client Interface is the single high-severity concern in Concho’s assessment. The current front-end (6,682 LOC, 2.3% of the project) is implemented in jQuery 3.6 and vanilla JavaScript targeting ES5, with browserify as the module bundler, popper.js for tooltip positioning, and Bootstrap 4.6 for layout. The stack was reasonable circa 2016–2018, but in 2026 it constrains the team on three axes: (1) no component model, so reusable UI is copy/paste at the template level rather than encapsulated components; (2) no reactive change detection, so screens that touch many fields (Annual Receipts, Gift Batch posting) hand-wire DOM updates; (3) developer recruitment friction — jQuery experience is increasingly rare in junior hires.
The Gift Processing modernization slice replaces this layer wholesale with Angular 20 (standalone components, signals, reactive forms, the official Angular Material accessibility surface). All five in-scope screens — Gift Batches, Recurring Gifts, Motivations, Bank Import, Annual Receipts — are rebuilt as Angular components consuming the new ASP.NET Core 10 REST endpoints. Section 7 walks through one example end-to-end. The legacy jQuery client remains in production for non-Gift-Processing surfaces (Partner, GL, Reporting) during the strangler-fig coexistence window, but the slice is fully retired from a UI-stack perspective.
Multi-Currency Performance in the Finance Operations Engine (Medium — Mitigated by Target)
The Finance Operations Engine is the largest architectural element (49,362 LOC, 17.2%) and houses the multi-currency, multi-country tax-deductibility, and ledger-posting math that defines Petra’s value. Concho flags it as medium severity not because the math is wrong but because (a) workflow complexity is concentrated — the engine carries 70% of the system’s HIGH-complexity workflows per the workflow-complexity artifact — and (b) automated batch operations (annual receipts, recurring-gift processing) have no observability today, making bottlenecks invisible until they show up as user-reported slowdowns.
The target architecture mitigates rather than retires this concern: the calculations themselves are correct domain logic that must be preserved verbatim, and they cross the modernization boundary unchanged (see Section 8 for the per-currency rounding example). What the target adds is observability — OpenTelemetry instrumentation through Serilog and Azure Application Insights gives per-batch latency, throughput, and per-currency calculation timing; the FluentValidation pipeline replaces hand-rolled TVerificationResult validation with consistent ProblemDetails responses; xUnit + WebApplicationFactory testing brings the engine under coverage that the legacy NUnit suite touches only at the integration boundary. The bottleneck risk is not eliminated — it is made visible, which is the precondition for fixing it when it materializes.
TPetraPrincipal Session Authentication (Medium — Retired by Target)
The Security Authorization Framework (6,685 LOC) is built on TPetraPrincipal + TOpenPetraOrgSessionManager, a custom session-based auth scheme rooted in .NET Remoting conventions. Sessions are stored server-side, sessions cookies are scoped to the ASP.NET host, and credential validation happens against a Petra-specific user store with custom permission tables. Concho flags this as medium severity for the absence of modern patterns: no OAuth flow, no JWT bearer tokens, no federation, no MFA support, no audit-trail of token issuance separate from request logs.
The target retires the framework entirely. Section 4 specifies Microsoft Entra ID (formerly Azure AD) for user authentication via the OIDC authorization-code-with-PKCE flow, JWT bearer tokens for API authorization, and Managed Identity for service-to-service authentication (Gift Processing API → Receipt Generation Service, Gift Processing API → Azure Key Vault, Gift Processing API → Azure Service Bus). Petra’s permission-table semantics are preserved as role claims on the JWT — the modernization keeps the authorization model while replacing the authentication mechanism. Annual receipt PDF access (today gated by the session cookie + a custom token table) is replaced by short-lived SAS URIs minted by the Gift Processing API after a JWT-authenticated request.
Medium-Severity Concerns Outside the Slice
XML Report Template Definitions (Out of Scope). The proprietary “NO-SQL” report template syntax (39,992 LOC, 14.0% of the analyzed source) is a significant maintenance surface but lives entirely outside the Gift Processing slice. Receipt PDF generation in the modernized service uses an HTML/CSS template processed by a headless renderer rather than the legacy XML templates. The legacy report templates continue to drive the GL, Partner, Conference, and Reporting subsystems during strangler-fig coexistence and are inventoried here as future-investment debt — not a debt the Gift Processing modernization addresses.
Partner Management Transaction Complexity (Out of Scope). TPartnerEditWebConnector coordinates many distinct CRUD operations within a single transaction boundary, and Concho flags this as a transaction-management risk. The Gift Processing slice consumes Partner data read-only through a REST bridge (validation of donor_partner_key and recipient_partner_key) — it does not write to the Partner aggregate. The Partner write surface remains on the legacy stack post-cutover; its modernization is a future slice, not this one.
Low-Severity Concerns
- Common Utilities Framework — Concho flags shared
ArrayListandUtilitiesclasses as a tight-coupling risk. The target reduces this through dependency injection, scoped services, and typed collections (List<T>,IReadOnlyList<T>) replacing the non-generic legacy collections. Coupling is mitigated, not eliminated — some shared helpers (currency formatting, partner-key parsing) remain as a smallShared.Domainlibrary on the .NET 10 side. - Development Tools Suite —
TinyWebServerand the assorted in-tree utilities are retired by moving to Kestrel + the standarddotnetCLI + Azure DevOps / GitHub Actions tooling. No bespoke development server is needed; the Angular CLI handles the SPA dev server independently.
6.4 Architectural Strengths to Preserve
Concho’s assessment identifies 5 architectural strengths. Each is paired below with an explicit note on how the target architecture preserves the underlying property, because a section that catalogs only debt reads as a hatchet job and undermines stakeholder trust in the analysis.
- XML-driven schema generation provides single-source-of-truth. (Data Layer.Database Schema) The
db/petra.xmlmaster schema is the canonical definition of every table, field, and relationship. The target preserves the single-source-of-truth property by keeping a code-generation step — the EF Core entities for the Gift Processing slice are themselves generated from a curated subset ofpetra.xmlfor the 8 gift tables, ensuring that schema drift between the legacy and modern stores is mechanically prevented during coexistence (and discoverable via migration diffing thereafter). - Code Generation Pipeline (TDataDefinitionParser / TDataDefinitionStore). (Build & Deployment.Code Generation) The pipeline that transforms XML schema into C# code, RPC interfaces, and database DDL is automated, repeatable, and well-isolated. The target preserves the idea of generated infrastructure but replaces the NAnt-driven implementation with EF Core migrations + a one-time generator. The generator is retired once the EF Core baseline is committed; the property of “schema changes propagate without manual edits” is retained.
- Multi-database abstraction. (Data Layer.Data Access) The legacy
TDBTypeenum supports PostgreSQL, MySQL, and SQLite through a unified connection-factory pattern. The target collapses to PostgreSQL only (Azure Database for PostgreSQL Flexible Server) but preserves the abstraction property at a different level: EF Core providers + repository interfaces let the team add a different store later without rewriting business code. The fact that Petra runs on PostgreSQL today made the modernization’s engine choice mechanical, not contested. - Business Rules Validation Engine (TVerificationResult). (Cross-Cutting Concerns.Validation Framework) The cross-layer validator pattern enforces business rules consistently across modules and is the single most reused infrastructure in the codebase. The target preserves the property — consistent validation across all API boundaries — through FluentValidation, with the same rules expressed as validator classes and the same domain-exception → RFC 7807 ProblemDetails translation in the middleware. The validation semantics are preserved verbatim; only the framework changes.
- Integration Test Infrastructure. (Test.Integration Tests) The NUnit-based integration suite (9,461 LOC) provides specialized fixtures for partner editing, gift processing, and financial-period rollover. The target preserves this property — comprehensive integration coverage — through xUnit + WebApplicationFactory + Testcontainers (PostgreSQL), with the same fixture concepts ported (a
GiftBatchFixturethat seeds a posted/unposted batch pair is a direct port of the legacy NUnit setup). The 80% coverage target in Section 4 is set explicitly to honor the strength.
6.5 Source-Language Anti-Patterns Observed
The active dotnet-angularjs source profile carries an antiPatternsToCheck list — common framings that get applied incorrectly to .NET+AngularJS prospects. The following items from that list are observable in the Petra codebase and are called out here so the modernization plan handles them correctly:
- .asmx is not “hard SOAP.” Petra’s
TGiftTransactionWebConnectorand related.asmxendpoints use the SOAP envelope shape but a JSON wire format with binary-serialized SortedList parameters — closer to JSON-RPC over SOAP framing. The modernization narrative in Section 8 treats this as “HTTP-RPC → REST”, not “SOAP → REST”, because the SOAP framing is cosmetic. - Code-gen-from-XML is the source of truth. The data access layer (
*.Generated.csfiles emitted byTDataDefinitionParser) is not hand-maintained — editing the generated files is futile. The target retires the generation pipeline first (one-time export to EF Core entities for the 8 gift tables) before the EF Core migration begins, so the generated layer cannot overwrite EF Core changes mid-modernization. - Multi-database assumption. The connection string and
TDBTypeabstraction support PostgreSQL, MySQL, and SQLite. Petra production runs on PostgreSQL; the modernization commits to PostgreSQL only on the modern side, but the legacy stack continues to run against whichever backend it is configured for during coexistence. - AngularJS framing does not apply. Petra uses jQuery, not AngularJS — the source profile shares an ID with AngularJS prospects but Petra’s UI is older. The modernization plan treats this as a jQuery → Angular rewrite, not an AngularJS → Angular upgrade. The migration is years lighter than an
ngUpgrade-based hybrid would be, because there is no Angular 1.x scope hierarchy to bridge. - Mono FastCGI hosting. Petra’s Linux deployments run under
fastcgi-mono-server4with Apache or nginx in front. This is incompatible with Azure App Service Linux. The target replaces it with Kestrel + the standard ASP.NET Core hosting model; no Mono runtime is involved in the modern slice.
6.6 Debt Outside Migration Scope
Two of the seven Concho-surfaced concerns are real architectural debt but live outside the Gift Processing modernization slice (Section 2):
- XML Report Template Definitions. 39,992 LOC of proprietary “NO-SQL” report templates drive the GL, Partner, Conference, and Reporting subsystems. The modernization slice produces HTML/CSS receipt templates only; the legacy report templates continue to operate on the legacy stack. A future modernization slice would inventory these templates and decide whether to port them to a modern reporting engine (e.g., FastReport, JS Reports, or HTML/PDF) or rewrite the subsystem they belong to entirely.
- Partner Management Operations.
TPartnerEditWebConnectorremains on the legacy stack. The Gift Processing slice consumes Partner data read-only through a REST bridge; the Partner write surface and its transaction-management complexity are inherited by the legacy system until a Partner-focused modernization slice is commissioned.
Calling these out as out-of-scope is deliberate: stakeholders should know they are real debts that the engagement does not address. A future investment case for Partner or Reporting modernization would inherit these line items as starting evidence.
6.7 Outdated Dependencies
Library-level debt is a different category from architectural debt and gets its own catalog. Concho’s file inventory confirms two dependency manifests in the Petra source tree:
csharp/ThirdParty/packages.config— NuGet packages for the .NET Framework 4.7 server (19 packages, 35 lines).js-client/package.json— npm packages for the jQuery web client (13 dependencies including dev/build tooling, 37 lines).
Two other .csproj files exist in the source (inc/nanttasks/NanttasksForDevelopers.csproj and inc/template/vscode/template.csproj) but reference only NAnt build assemblies and a template skeleton respectively — no third-party application dependencies. They are excluded from the catalog.
The two manifests below give stakeholders the actual bill of materials, classified on lifecycle status (current / outdated / EOL-major / EOL-project / known-CVE) and target action (removed / replaced / upgraded / kept-with-risk / outside-slice).
Server-side — csharp/ThirdParty/packages.config (NuGet)
<?xml version="1.0" encoding="utf-8"?> <packages> <package id="NUnit" version="3.13.3"/> <package id="NUnit.Console" version="3.15.2"/> <package id="NUnit.ConsoleRunner" version="3.15.2"/> <package id="MySqlConnector" version="2.2.5" /> <package id="Npgsql" version="4.1.10"/> <!-- pinned: 5.x requires .NET 5+ --> <package id="System.Buffers" version="4.5.1" /> <package id="System.Diagnostics.DiagnosticSource" version="7.0.0"/> <package id="Microsoft.Bcl.AsyncInterfaces" version="7.0.0" /> <package id="System.Runtime.CompilerServices.Unsafe" version="6.0.0"/> <package id="System.Memory" version="4.5.5" /> <package id="System.Text.Json" version="7.0.1" /> <package id="System.Threading.Tasks.Extensions" version="4.5.4" /> <package id="System.ValueTuple" version="4.5.0" /> <package id="System.Text.Encodings.Web" version="7.0.0" /> <package id="MimeKit" version="3.5.0" /> <package id="MailKit" version="3.5.0" /> <package id="System.Runtime" version="4.3.1" /> <package id="Portable.BouncyCastle" version="1.9.0" /> <package id="HtmlAgilityPack" version="1.11.46"/> <package id="Newtonsoft.Json" version="13.0.2"/> <package id="libsodium-net" version="0.10.0"/> <package id="SharpZipLib" version="1.3.3" /> <!-- pinned: 1.4.x requires .NET 6+ --> <package id="PDFsharp" version="1.50.5147"/> <package id="NPOI" version="2.6.0" /> </packages>
Front-end — js-client/package.json (npm)
{
"name": "openpetra-client-js",
"version": "2018.2.0",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
"axios": "^0.21.4",
"bootstrap": "^4.6.1",
"browserify": "^16.5.2",
"browserify-css": "^0.15.0",
"cypress": "^5.6.0",
"i18next": "^10.6.0",
"i18next-browser-languagedetector":"^2.2.4",
"i18next-xhr-backend": "^1.5.1",
"jquery": "^3.6.0",
"popper.js": "^1.16.1",
"uglify-js": "^3.16.0"
}
}
Dependency catalog — lifecycle + target action
| Dependency | Manifest | Current | Lifecycle | Target Action | Notes |
|---|---|---|---|---|---|
| .NET Framework | (runtime, not manifested) | 4.7 | EOL-major | Replaced by target | 4.7.0 is past Microsoft mainstream support; modern .NET 10 is LTS. Underlies every server package below. |
| jquery | package.json | 3.6.x | outdated | Removed by target | Not used in Angular 20 client. Retired with the Web Client Interface. |
| bootstrap | package.json | 4.6.1 | EOL-major | Replaced by target | Bootstrap 4 reached EOL January 2023; Bootstrap 5 still maintained. Target replaces with Angular Material. |
| browserify | package.json | 16.5.2 | EOL-project | Removed by target | Largely abandoned; modern Angular uses the Angular CLI + esbuild/Vite. Replaced wholesale by the Angular toolchain. |
| popper.js | package.json | 1.16.1 | EOL-major | Removed by target | Renamed to @popperjs/core at 2.x; 1.x no longer maintained. Replaced by Angular CDK overlay primitives. |
| axios | package.json | 0.21.4 | known-CVE | Removed by target | Pre-1.0 axios versions carry several CVEs (e.g., SSRF in 0.x). Angular HttpClient replaces the HTTP layer entirely. |
| cypress | package.json | 5.6.0 | outdated | Replaced by target | Cypress 13.x current. Target replaces with Playwright (component + e2e) per the standard concho.ai testing convention. |
| uglify-js / browserify-css | package.json | 3.16 / 0.15 | EOL-project | Removed by target | Bundler-era tooling. Angular CLI handles minification (Terser) and CSS bundling natively. |
| i18next (+ adapters) | package.json | 10.x | EOL-major | Replaced by target | i18next 23.x current (major rewrites). Target uses Angular i18n + ASP.NET Core IStringLocalizer + .resx resources. |
| @fortawesome/fontawesome-free | package.json | 5.15.4 | outdated | Replaced by target | Font Awesome 6.x current; Angular Material uses its own icon set. Replaced as part of the SPA rebuild. |
| Newtonsoft.Json | packages.config | 13.0.2 | outdated (still maintained) | Replaced by target | Not EOL but Microsoft recommends System.Text.Json for new code. Target uses source-generated System.Text.Json; behavioral diffs reviewed per call. |
| Npgsql | packages.config | 4.1.10 | EOL-major | Upgraded by target | 4.x is past EOL on .NET Framework. Target uses Npgsql 8.x with EF Core 10 provider. (Comment in source notes pin: “5.x requires .NET 5+” — that constraint dissolves on .NET 10.) |
| MySqlConnector | packages.config | 2.2.5 | outdated | Removed by target | MySQL is not a target backend (PostgreSQL only). Removed in the modern slice; remains in legacy. |
| NUnit (+ Console, ConsoleRunner) | packages.config | 3.13.3 / 3.15.2 | outdated | Replaced by target | NUnit still maintained but the target standardizes on xUnit + WebApplicationFactory (Section 4.3.1). Existing NUnit fixtures port one-to-one. |
| SharpZipLib | packages.config | 1.3.3 | outdated | Upgraded by target | Source comment notes pin: “1.4.x requires .NET 6+” — constraint dissolves on .NET 10. Used for ZIP-bundled bank statement imports. |
| libsodium-net | packages.config | 0.10.0 | EOL-project | Replaced by target | Pre-1.0 binding, largely unmaintained. Target uses the modern NSec.Cryptography or built-in System.Security.Cryptography primitives on .NET 10. |
| PDFsharp | packages.config | 1.50.5147 | outdated | Upgraded by target | PDFsharp 6.x current with .NET 10 support and modern font handling. Used by Receipt Generation Service for the annual-receipt PDF rendering. |
| NPOI | packages.config | 2.6.0 | outdated | Upgraded by target | NPOI 2.7.x current with active maintenance; used for Excel-format bank statement parsing. |
| MimeKit / MailKit | packages.config | 3.5.0 | outdated | Upgraded by target | 4.x current with .NET 10 support. Used for emailing donor receipts. |
| HtmlAgilityPack | packages.config | 1.11.46 | outdated | Outside slice | Used by legacy report-template processing (Section 6.6 out-of-scope debt). Remains on legacy stack. |
| Portable.BouncyCastle | packages.config | 1.9.0 | outdated | Upgraded by target | Modern BouncyCastle.Cryptography 2.x targets .NET Standard 2.0 + .NET 6+. Used for SEPA mandate signing. |
| System.* facade packages | packages.config | 4.3–7.0 | outdated | Removed by target | The System.Buffers, System.Memory, System.ValueTuple, System.Threading.Tasks.Extensions, etc. polyfill packages are only required because .NET Framework 4.7 lacks those types in mscorlib. On .NET 10 they are in-box and the explicit references disappear. |
| NAnt | (build, not in NuGet) | 0.92 | EOL-project | Removed by target | Last NAnt release: 2012. Target build system is dotnet CLI + MSBuild + GitHub Actions / Azure Pipelines. |
| Mono FastCGI | (runtime, not manifested) | 4.x | EOL-project | Removed by target | Mono project effectively deprioritized FastCGI server. Target runs on Kestrel + Azure App Service Linux. |
Specific high-priority items requiring narrative.
axios 0.21.4 is the single known-CVE entry. Pre-1.0 axios has documented SSRF and prototype-pollution issues (CVE-2021-3749, CVE-2023-45857) that affect any application that forwards user-supplied URLs or parses untrusted JSON without input bounds. The migration path is not “upgrade axios” — it is “replace with Angular HttpClient,” which removes the axios surface entirely. No additional defensive code is needed in the legacy client during coexistence because the affected screens are within the slice and are being rewritten in Angular.
.NET Framework 4.7 is the load-bearing runtime EOL. Although Microsoft still ships security patches for 4.8, version 4.7.0 specifically is past mainstream support. Every server-side dependency in packages.config is pinned to a version that compiles against 4.7; removing the runtime requires committing to the .NET 10 baseline across the slice. The Gift Processing modernization treats this as the modernization’s anchoring decision — the platform target (Section 4) is .NET 10 specifically because the runtime is past EOL.
browserify / Bower-style toolchain (browserify, browserify-css, uglify-js) is the front-end’s build pipeline and is collectively retired by adopting the Angular CLI. Bower itself is not in package.json (good — Petra already moved off Bower at some prior point) but the bundler choice is similarly stale. No upgrade path exists; the Angular CLI is the replacement.
Summary. Of 28 dependencies surveyed across the two manifests plus three runtime-level entries (.NET Framework, Mono FastCGI, NAnt):
- 10 removed by target (jquery, browserify, browserify-css, uglify-js, popper.js, axios, Mono FastCGI, NAnt, MySqlConnector, System.* polyfills)
- 10 replaced by target with a different package serving the same role (.NET Framework, bootstrap, cypress, i18next, FontAwesome, Newtonsoft.Json, NUnit, libsodium-net, plus stack-wide replacements)
- 7 upgraded by target (Npgsql, SharpZipLib, PDFsharp, NPOI, MimeKit/MailKit, Portable.BouncyCastle)
- 1 kept outside slice (HtmlAgilityPack — remains on legacy stack for report templates)
- 0 kept with risk noted in the modern slice
The dependency burden of the modern Gift Processing slice is therefore dramatically smaller than the legacy footprint — most legacy packages exist to fill gaps that .NET 10 closes in-box (System.* polyfills) or to support a runtime + UI stack that is replaced entirely (jQuery + browserify era tooling).
7. UI/UX Transformation Examples
GiftBatches.html, GiftDetailEntry.html, MotivationPicker.html) running on jQuery + AngularJS 1.x collapse into a single Angular 20 unified workspace with live multi-currency totals, live period validation, a tax-deductible-percentage clamp, and a pre-commit GL journal preview that legacy never offered. The section closes with a six-scene walkthrough rendered from the storyboard markdown that drove this section's generation.
The Finance — Gift Processing subsystem has a heavy UI surface — gift batch headers, detail rows, donor lookups, motivation pickers, and posting confirmations that the legacy implementation spreads across separate jQuery/AngularJS HTML pages. Modernization to Angular 20 + ASP.NET Core 10 is an opportunity not just to re-implement those pages, but to consolidate them around the actual user journey. The use-case-discovery phase identified Gift Batch Entry through Posted GL as the highest-density use case in the subsystem (8 of 14 catalog business rules fire along its path), so it is the journey featured here.
Section 7.1 through 7.6 present the 1:1 screen translations — legacy screen on the left, modern Angular 20 mockup in the middle, platform-affinity wins on the right. Section 7.7 is the "Beyond 1:1" consolidated walkthrough: the unified Gift Batch Workspace shown in six successive scenes from empty state through pre-commit preview, commit, downstream GL view, and side-by-side coexistence with the legacy reader. Section 7.8 enumerates the modern UI design tokens; Section 7.9 (a separate fragment, appended below this section in the assembled report) is the complete field-inventory reference table derived from the same storyboard JSON.
7.1 Legacy UI Inventory
Concho’s get_files_for_architecture_layer_with_metadata("Presentation Layer") returned 12 legacy UI artifacts (6,682 lines) for the Gift Processing subsystem. The three that drive the canonical use case are:
| Legacy Artifact | Lines | Role | State held in |
|---|---|---|---|
js-client/src/forms/Finance/Gift/GiftEntry/GiftBatches.html | ~310 | Batch list + new-batch modal | jQuery global $gpGiftBatches |
js-client/src/forms/Finance/Gift/GiftEntry/GiftDetailEntry.html | ~480 | Per-row gift entry grid | jQuery global $gpGiftDetail + localStorage |
js-client/src/forms/Finance/Gift/GiftEntry/MotivationPicker.html | ~220 | Motivation group + detail lookup modal | AngularJS $scope on a dedicated controller |
Server-side, all three pages talk to TGiftTransactionWebConnector in csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.Batch.cs — specifically the CreateAGiftBatch and PostGiftBatch operations. The state machine across them is BATCH_CREATED → DETAILS_ENTERED → PERIOD_VALIDATED → AMOUNTS_CALCULATED → BATCH_POSTED → GL_JOURNAL_EMITTED, but no single legacy screen renders that whole pipeline; the clerk has to hop between three pages plus a server-side modal to walk it.
7.2 Gift Batches List (1:1 Translation)
The Gift Batches list page is the entry point for the use case. It enumerates today’s batches (unposted and posted), highlights which are eligible for posting, and offers a "New Gift Batch" action.
Legacy: GiftBatches.html (jQuery + AngularJS)
| # | Date | Status | Description | Total |
|---|---|---|---|---|
| 44 | 19/05/26 | Posted | April direct debits | 8,420.00 |
| 45 | 20/05/26 | Posted | Anonymous gifts | 1,250.00 |
| 46 | 21/05/26 | Unposted | May 2026 EUR donations | 2,460.00 |
- Status filter cannot be combined with date range without a server round-trip.
- Posting requires a separate confirmation page (server-side modal alert).
- Cross-screen state held in jQuery globals; refresh discards the workspace.
Modern: Gift Batches (Angular 20 + Tailwind)
| # | Date | Status | Description | Total (EUR) |
|---|---|---|---|---|
| 44 | 19 May | ● Posted | April direct debits | €8,420.00 |
| 45 | 20 May | ● Posted | Anonymous gifts | €1,250.00 |
| 46 | 21 May | ● Unposted | May 2026 EUR donations | €2,460.00 |
Platform Affinity Wins
- BR-GIFT-007 status semantics visible — coloured status pills replace plain text. The unposted row is amber, posted is green.
- Composable filter chips — status + date + count chips render client-side; no server round-trip per filter combination.
- Single-page navigation — the "+ New Gift Batch" button opens the unified workspace inline; no modal-on-modal stacking.
- Currency-aware totals — the Total column reads in the ledger’s base currency (BR-GIFT-003); legacy displayed raw decimals with no currency indicator.
7.3 Gift Detail Entry (1:1 Translation)
The legacy Gift Detail Entry page hosts the inline grid of donations within a batch. Donor selection requires an exact partner key at a blank prompt; motivation codes are entered by typing into a separate field; no live currency conversion is shown until save. The modern equivalent puts donor autocomplete, motivation auto-fill, and live multi-currency totals into a single grid.
Legacy: GiftDetailEntry.html
| Row | Donor key | Motivation | Amount | Cur | Tax % |
|---|---|---|---|---|---|
| 1 | |||||
| 2 |
- Donor key entered as exact 8-digit numeric — no autocomplete (BR-GIFT-011 partner class is invisible).
- Motivation typed as
GROUP/DETAILstring — no pick list, no auto-fill from partner class. - Tax % accepted as raw integer — no live clamp; BR-GIFT-009 enforces 0–100 only on save.
- No live currency conversion to base or international (BR-GIFT-003 only triggers on Save).
Modern: Gift Detail Grid (Angular 20)
| Donor | Motivation | Amount | Cur | Tax % | Base (EUR) |
|---|---|---|---|---|---|
| Müller, Heinrich [UNIT] | GIFT › SUPPORT | 500.00 | USD | 87 | €454.55 |
| Berger, Anneliese [FAMILY] | GIFT › SUPPORT | 200.00 | EUR | 100 | €200.00 |
| Kongo Mission Fund [UNIT/FIELD] | GIFT › FIELD | 1,800.00 | EUR | 100 | €1,800.00 |
Platform Affinity Wins
- BR-GIFT-011 motivation auto-fill — selecting a donor with class
UNIT/FIELDauto-assigns motivation detailFIELD;UNIT/KEYMINwould assignKEY_MIN; everything else assignsSUPPORT. Side-panel preview shows the mapping before commit. - BR-GIFT-009 live clamp — the Tax % column is bound to a slider/number input that rejects values outside 0–100; legacy clamps only at save.
- BR-GIFT-003 live FX — the Base column updates as you type the transaction amount; legacy showed base only after Save.
- Period chip — the green "Period 5 open" pill from BR-GIFT-002 surfaces the validation at glance time, not save time.
7.4 Motivation Picker (1:1 Translation)
The legacy Motivation Picker is a modal triggered from the Detail Entry page; in the modern unified workspace it becomes a docked side-panel that auto-updates as the donor changes. Both surface the same data — motivation groups, details, and the partner-class-driven auto-assignment — but the modern variant is always visible and always reflects the row currently in focus.
Legacy: MotivationPicker.html (modal)
| Code | Description |
|---|---|
| SUPPORT | General Worker Support |
| FIELD | Field Project |
| KEY_MIN | Key Ministries |
- Modal modal on top of the Detail Entry page — the underlying grid is blocked.
- No preview of how the choice maps from partner class (BR-GIFT-011 is hidden).
- Selection commits on OK click; closing without OK loses the change.
Modern: Motivation Side-Panel
UNIT » type FIELD| Group | Detail | Receipt? |
|---|---|---|
| GIFT | FIELD | ✓ |
| GIFT | KEY_MIN | ✓ |
| GIFT | SUPPORT | ✓ |
| MEMBERFEE | ANNUAL | — |
Platform Affinity Wins
- BR-GIFT-011 visible — partner class → motivation mapping rendered explicitly; user can override but sees what the auto-assignment would be.
- Always-visible side-panel — no modal-on-modal stacking; the underlying grid stays active.
- Receipt-eligibility column — the modern table shows the BR-GIFT-004 receipt flag inline so the clerk can see at a glance whether a motivation makes the gift receipt-eligible.
7.5 Period Validation Chip (1:1 Translation)
BR-GIFT-002’s Financial Period Validation is, in the legacy system, an invisible save-time check that throws a server error if the effective date is outside an open period. In the modern workspace it surfaces as a chip in the header that turns green/amber/red as the date is typed.
Legacy: save-time alert
AForceEffectiveDateToFit=false. Please choose an open period."
- Validation fires only on Save; the user might enter the entire batch before seeing the error.
- The force-fit toggle (
AForceEffectiveDateToFit) is exposed only as a command-line argument to the server endpoint — not a UI control.
Modern: live period chip
Platform Affinity Wins
- Glance-time validation — the period chip resolves on every keystroke; the user sees the validity before continuing.
- Force-fit surfaced as a checkbox — what was a hidden server parameter is now a UI control with a tooltip linking to the BR.
- Same backend semantics — the chip queries the same
a_accounting_periodtable; no new data is introduced.
7.6 Posted Confirmation (1:1 Translation)
The final 1:1 screen is the post-commit confirmation. Legacy Petra shows a plain modal alert ("Batch 46 posted successfully"); the modern workspace converts the entire page state from amber to green and surfaces the assigned GL journal number with a deep link to the GL view.
Legacy: modal alert
- No journal number; the clerk has to navigate to GL051 to find it.
- No timestamp; audit-trail reconstruction requires a separate report.
Modern: Status bar + toast
GL-2026-05-21-014 assigned. View GL Posting →
Platform Affinity Wins
- Journal number surfaced — clerk no longer hops to GL051 to find the journal id; BR-GIFT-007 confirmation includes the assigned journal id.
- Inline audit — posted-by user, timestamp, and immutability notice are part of the page state, not a separate report.
- Deep link to GL view — the bridged-to-legacy GL posting is one click away (Scene 5 in the walkthrough).
7.7 Beyond 1:1 — The Unified Gift Batch Workspace (Multi-Scene Walkthrough)
While screen-for-screen conversions are useful, legacy modernization often offers the opportunity to combine multiple screens into a single cohesive experience. In Petra’s gift-processing slice, three legacy pages (GiftBatches.html, GiftDetailEntry.html, MotivationPicker.html) all participate in a single user journey — the daily gift-batch entry → post cycle. The modern Angular 20 unified workspace renders all three concerns at once, with responsive layout, modern form elements (autocomplete, sliders, chips), interaction patterns like live multi-currency conversion and pending-to-confirmed state transitions, and progressive disclosure of the GL posting payload.
The unified view below was auto-generated by the Concho modernization workflow. For the human-in-the-loop part, a companion storyboard in Markdown format is also auto-generated and can be edited by humans and fed back through the workflow to regenerate the screen mockups. "Scene" is a standard storyboarding term for a distinct moment in the user’s interaction; the walkthrough below has six scenes covering the empty state, donor lookup, pre-commit preview, commit, downstream GL view, and side-by-side legacy coexistence.
Show Storyboard Markdown
# Storyboard: Gift Batch Entry through Posted GL — Draft (Inferred from Code Analysis) **Source:** Concho workflow entities + TGiftTransactionWebConnector operations (cycle 23), plus the use-case-discovery handoff (use case 1) and the behavioral-rules catalog (BR-GIFT-001/002/003/007/008/009/010/011). **Programs Combined:** legacy GiftBatches.html, GiftDetailEntry.html, MotivationPicker.html, posting confirmation modal. **Status:** DRAFT — inferred from TGiftTransactionWebConnector state machine + behavioral-rules catalog. **Flow:** BATCH_CREATED → DETAILS_ENTERED → PERIOD_VALIDATED → AMOUNTS_CALCULATED → BATCH_POSTED → GL_JOURNAL_EMITTED **Actor:** Finance Operations Clerk, processing the day’s incoming gift cheques and electronic transfers for a European non-profit ledger denominated in EUR. --- ## Scene 1: Three Screens Become One - User: the finance clerk. - User action: The finance clerk opens the modern unified route /forms/Finance/Gift/GiftEntry/UnifiedGiftBatch. - Visible state: The workspace renders in empty state — header (ledger picker, effective date, batch number reserved as "next: 47"), an empty Gift Details grid, a collapsed Motivation Picker side-panel, Multi-Currency Summary showing zeros, GL Journal Preview saying "No posting payload yet". - Business rule fired: BR-GIFT-001 has been *reserved* but no atomic increment occurs until the first save. - Platform affinity win: legacy splits this workspace across three jQuery/AngularJS pages. ## Scene 2: Donor Search with Autocomplete - User: the finance clerk. - User action: The finance clerk types "Mül" into the Donor field on the first gift-detail row. - Visible state: Autocomplete dropdown opens with three matches showing partner key, partner class, last gift date, YTD total in EUR, receipt cadence. Motivation Picker side-panel previews the motivation assignment for the highlighted match. Effective-date chip shows green "Period 5 (May 2026) — open". - Business rule fired: BR-GIFT-002 lights up the period chip; BR-GIFT-011 drives the live motivation hint. - Platform affinity win: legacy required exact 8-digit donor key at a blank prompt with no preview. ## Scene 3: Preview / Pending State — Multi-Currency Allocation - User: the finance clerk. - User action: The finance clerk selects Müller, Heinrich [UNIT · 43005001], types 500.00 into Amount, selects USD, types 87 into Tax %. - Visible state: row in amber pending state; Multi-Currency Summary live (USD 500.00, EUR 454.55, USD 500.00 intl); Tax % shows 87.00 within bounds; GL Journal Preview populated with amber dry-run: Dr. 0100-Bank-EUR 454.55, Cr. 4500-Gift Income 395.46, Cr. 4501-Non-Deductible 59.09 — no journal number; Post button labelled "Preview only — click to post". - Business rule fired: BR-GIFT-003, BR-GIFT-009 (clamp), BR-GIFT-010, BR-GIFT-008 (modified_detail=0 staged). - Platform affinity win: legacy computed FX only on save; no GL preview ever possible. ## Scene 4: Commit / Confirmed State — Batch Posted - User: the finance clerk. - User action: The finance clerk clicks the Post Batch button. Confirmation toast slides in. - Visible state: amber rows transition to green; "Est." prefix dropped; header shows "Batch #47 · Status: POSTED · Posted by the finance clerk · 2026-05-21 14:32 UTC"; GL Journal Preview now shows journal number GL-2026-05-21-014; Post button replaced by "Posted" chip + "View GL Posting →" link; lock icons appear on rows. - Business rule fired: BR-GIFT-007 (Unposted → Posted + immutability); BR-GIFT-001 (atomic ++46 → 47); BR-GIFT-008 (modified_detail=0 baseline locked). - Platform affinity win: legacy posting was a modal alert with no journal reference. ## Scene 5: Downstream Consequence — GL Posting Bridge - User: the finance clerk. - User action: The finance clerk clicks "View GL Posting →" in the status bar. - Visible state: GL Posting panel expands inline; journal number GL-2026-05-21-014; three journal lines; bridged-status pill "Modern Gift Processing API → Azure API Management → Legacy GL (.asmx)"; Posting Receipt subsection with legacy GL ack id; chip "Bridged via classical-strangler-fig". - Business rule fired: BR-GIFT-007 (Posted gate); BR-GIFT-003 (EUR base amounts with USD memo). - Platform affinity win: legacy required navigating to a separate program to look up the GL effect. ## Scene 6 (optional): Side-by-Side Coexistence - User: Tony, the finance manager auditing the day’s work. - User action: Tony clicks toolbar toggle "Show legacy view". - Visible state: page splits; legacy GiftBatches.html iframe renders read-only on the right; both halves show batch #47 with matching donor, amount, and Posted status; banner reads "Coexistence: Modern API owns writes for posted/unposted gift-batch state. Legacy reads continue against the same database." - Business rule fired: coexistence-level; BR-GIFT-001/007/008 invariants verified across both readers. - Platform affinity win: classical-strangler-fig commitment becomes auditable in the UI.
Scene 1: Three Screens Become One
The finance clerk opens the modernized unified route /forms/Finance/Gift/GiftEntry/UnifiedGiftBatch. The workspace renders in its empty initial state — one route, three panels, no navigation to other pages required.
Triggering user action: The finance clerk clicks the bookmark Gift Batch Workspace in their browser, which routes Angular to the unified view.
| Donor | Motivation | Amount | Cur | Tax % | Base (EUR) |
|---|---|---|---|---|---|
| No detail rows yet — click the Donor field to begin. | |||||
USD 0.00 · EUR 0.00 · USD 0.00
Three legacy programs collapse into one route. The header panel takes the place of GiftBatches.html, the inline grid takes the place of GiftDetailEntry.html, and the side-panel takes the place of MotivationPicker.html. No business rule has fired yet — BR-GIFT-001 has reserved the next batch number but the atomic increment will not occur until the finance clerk posts. The first state transition happens when the finance clerk interacts with the donor field, which leads us to Scene 2.
Scene 2: Donor Search with Autocomplete
The finance clerk starts typing the donor’s name into the Donor field on the first detail row. The autocomplete dropdown opens with three matches, each carrying the financial context the legacy system never surfaced: partner key, partner class, last gift date, YTD total in the ledger base currency, and a receipt-cadence indicator. The Motivation Picker side-panel updates live to preview the motivation that BR-GIFT-011 will assign for the highlighted match.
Triggering user action: The finance clerk types Mül into the Donor input.
| Donor | Motivation | Amount | Cur | Tax % | Base (EUR) |
|---|---|---|---|---|---|
|
Mül|
Müller, Heinrich [UNIT · 43005001]
YTD €3,200.00 · last gift 12 Apr 2026 · annual receipt
Müller-Schmidt, Petra [FAMILY · 43005104]
YTD €540.00 · last gift 02 Mar 2026 · per-gift receipt
Mülheim Field Project [UNIT/FIELD · 43009001]
YTD €1,840.00 · last gift 18 Apr 2026 · no receipt |
— | — | — | — | — |
Partner class:
UNITBR-GIFT-011 will assign → GIFT › SUPPORT
Two business rules light up at glance time. BR-GIFT-002 (Financial Period Validation) renders the green "Period 5 / May 2026 — open" pill in the header because the finance clerk’s effective date is bound to an open accounting period. BR-GIFT-011 (Motivation Detail Assignment by Partner Class) previews the motivation that will be auto-assigned once the finance clerk selects a match — their cursor is on Heinrich Müller (partner class UNIT, no specifically-linked motivation detail, no key-ministry mapping), so the side-panel previews GIFT › SUPPORT. In Scene 3, the finance clerk will pick the match and start typing amounts.
Scene 3: Preview / Pending State — Multi-Currency Allocation
The finance clerk selects Heinrich Müller from the dropdown and types the amount, transaction currency, and tax-deductible percentage. The detail row enters pending state (amber background), and three side-panels recompute in real time: the Multi-Currency Summary shows the three running totals; the Motivation Picker confirms the auto-assigned motivation; and the GL Journal Preview — a panel that legacy Petra could never offer — shows the dry-run posting payload that will hit the GL when the finance clerk commits.
Triggering user action: The finance clerk selects Müller, types Amount 500.00, Currency USD, Tax % 87, and adds two more rows (a EUR-denominated family gift and an EUR-denominated field-project gift). They have not yet clicked Post.
| Donor | Motivation | Amount | Cur | Tax % | Est. Base (EUR) |
|---|---|---|---|---|---|
| Müller, Heinrich [UNIT] | GIFT › SUPPORT | 500.00 | USD | 87 | ~€454.55 |
| Berger, Anneliese [FAMILY] | GIFT › SUPPORT | 200.00 | EUR | 100 | €200.00 |
| Kongo Mission Fund [UNIT/FIELD] | GIFT › FIELD | 1,800.00 | EUR | 100 | €1,800.00 |
Transaction: USD 500.00 · EUR 2,000.00 · Base (EUR): €2,454.55 · International (USD): $2,700.00
Partner class:
UNIT/FIELDBR-GIFT-011 assigned → GIFT › FIELD
Four business rules now run live. BR-GIFT-003 (Multi-Currency Gift Processing) drives the three-currency Summary — Müller’s USD 500.00 gift is converted to EUR 454.55 at the 1.10 USD/EUR rate, while the two EUR-denominated rows pass through. BR-GIFT-009 (Tax Deductible Percentage Bounds) clamped the finance clerk’s 87 % to within the [0, 100] range; had they typed 120, the input would have rejected back to 100. BR-GIFT-010 (Tax Deductibility Compliance Calculation) recomputed the deductible / non-deductible split across all three currencies and projected it into the GL Journal Preview. BR-GIFT-008 (Unmodified Detail Filter) is implicit but visible — every row is staged with a_modified_detail_l = 0, the baseline that makes future GiftAdjustment workflows idempotent. None of this preview was possible in legacy Petra because the GL journal was constructed only at posting time and held in memory.
Scene 4: Commit / Confirmed State — Batch Posted
The finance clerk clicks Post Batch. The workspace transitions from amber to green: rows lock, the batch status flips from Unposted to Posted, the GL journal number is assigned, and a confirmation toast slides in. The same data is on the page; only its colour and metadata have changed.
Triggering user action: The finance clerk clicks in the GL Journal Preview panel.
GL-2026-05-21-014 assigned. View GL Posting →
| Donor | Motivation | Amount | Cur | Tax % | Base (EUR) | |
|---|---|---|---|---|---|---|
| Müller, Heinrich [UNIT] | GIFT › SUPPORT | 500.00 | USD | 87 | €454.55 | 🔒 |
| Berger, Anneliese [FAMILY] | GIFT › SUPPORT | 200.00 | EUR | 100 | €200.00 | 🔒 |
| Kongo Mission Fund [UNIT/FIELD] | GIFT › FIELD | 1,800.00 | EUR | 100 | €1,800.00 | 🔒 |
Transaction: USD 500.00 · EUR 2,000.00 · Base (EUR): €2,454.55 · International (USD): $2,700.00
✓ BR-GIFT-011 mapping locked.
The pending-to-confirmed transition is the visible representation of three database transactions that happened inside a single PostgreSQL BEGIN/COMMIT block: BR-GIFT-001 atomically incremented LastGiftBatchNumber from 46 to 47 (the next clerk to start a batch will see #48); BR-GIFT-007 flipped a_batch_status_c from Unposted to Posted and locked the rows against direct edit (subsequent changes require an explicit GiftAdjustment workflow); BR-GIFT-008 set a_modified_detail_l = 0 on every row to establish the idempotency baseline. The legacy posting confirmation was a one-line modal alert with no journal reference; in the modern workspace, the journal number, posted-by user, timestamp, and the immutability state are all part of the page state.
Scene 5: Downstream Consequence — GL Posting Bridge
The finance clerk clicks the View GL Posting → link in the status bar. Instead of navigating to a different program (as the legacy GL051 enquiry would have required), the modern workspace expands an inline panel that shows the GL journal as the legacy general ledger received it — including the strangler-fig bridge metadata. This makes the coexistence pattern visible to the user: the data lives in the modern Gift Processing API, but the canonical GL journal landed in the legacy GL (which remains the system of record for ledger balances until the GL itself is modernized in a later phase).
Triggering user action: The finance clerk clicks View GL Posting → in the confirmation toast or in the Status bar.
| Acct | Name | Debit (EUR) | Credit (EUR) | Memo |
|---|---|---|---|---|
| 0100 | Bank — EUR | 2,454.55 | — | GIFT-47 net (USD 500 + EUR 2000) |
| 4500 | Gift Income | — | 2,395.46 | Tax-deductible portion |
| 4501 | Non-Deductible Gift | — | 59.09 | Residual from BR-GIFT-010 split |
| Net | 2,454.55 | 2,454.55 | ✓ balanced | |
[Modern Gift Processing API]
|
v
[Azure API Management]
route: gl-journal-bridge
|
v
[Legacy GL (.asmx SOAP)]
endpoint: TGLBatchWebConnector
ack id: LGL-2026-014-ACK
LGL-2026-014-ACKTwo business rules quietly govern this view. BR-GIFT-007 is what permitted the panel to open at all — only Posted batches have an associated journal; unposted or cancelled batches return an empty panel. BR-GIFT-003 is visible in the journal lines themselves: the base-currency amount (€454.55) is the converted value of Müller’s USD 500 gift, while the memo retains the transaction-currency original. The platform-affinity win is "everything visible without leaving the page" — the legacy clerk would have had to navigate to GL051 (a separate program with its own login session) to see this; the modern clerk reads it inline.
Scene 6: Side-by-Side Coexistence (legacy mirror)
Tony, the finance manager, is auditing the day’s work. He wants to verify that the modern workspace and the legacy reader agree on the data — the contract that the classical-strangler-fig modernization plan promises. He clicks the toolbar toggle Show legacy view; the modern workspace stays where it is on the left, and the legacy GiftBatches.html page renders read-only on the right.
Triggering user action: Tony clicks the toolbar toggle .
| Donor | Amount | Cur | Status |
|---|---|---|---|
| Müller, Heinrich | 500.00 | USD | Posted |
| Berger, Anneliese | 200.00 | EUR | Posted |
| Kongo Mission Fund | 1,800.00 | EUR | Posted |
a_gift_batch via Angular 20 API client · latency 12 ms| Donor key | Amount | Cur | Status |
|---|---|---|---|
| 43005001 | 500.00 | USD | Posted |
| 43005203 | 200.00 | EUR | Posted |
| 43009001 | 1,800.00 | EUR | Posted |
a_gift_batch via legacy jQuery client · latency 86 msNo new business rule fires here — this is a coexistence-level check — but the scene is what makes the strangler-fig pattern auditable in the UI. Tony can verify in one screen that the modern endpoint and the legacy reader agree on the data. The platform-affinity win is the modernization plan’s commitment becoming first-class in the product: classical strangler-fig stops being plumbing and starts being a visible operational mode that the audit user can toggle.
7.8 Design Tokens and Modern UI Conventions
The Angular 20 mockups in this section use a small set of design tokens that recur across every screen and scene. Codifying them keeps the modernization deliberate — not ad-hoc — and makes them easy to enforce in the demo app the code-generation phase will produce.
| Token | Value | Used for |
|---|---|---|
| Primary accent | #1a4442 (Concho deep teal) | Buttons, headings, focus borders |
| Pending state | #fffbeb bg, #fde68a border, #92400e text | Amber rows, "est." labels, pre-commit projections |
| Confirmed state | #f0fdf4 bg, #bbf7d0 border, #166534 text | Posted rows, journal-assigned chip, ✓ ticks |
| Period chip (open) | #f0fdf4 bg, #166534 text | BR-GIFT-002 live period validation |
| Status — Unposted | #fffbeb bg, #92400e text | BR-GIFT-007 status pill in list/header |
| Status — Posted | #f0fdf4 bg, #166534 text | BR-GIFT-007 status pill after commit |
| Strangler bridge label | #fef3c7 bg, #92400e text | Coexistence indicator in Scenes 5 and 6 |
| Body font | 'Inter', sans-serif | All modern UI text |
| Code/journal blocks | 'Courier New', Courier, monospace · #1a1a1a bg · #ffffff fg | GL journal previews, strangler-route diagrams, code snippets |
The Section 7.9 fragment that follows enumerates every field on the unified workspace as a single inventory table. That table is the ground truth that the mockups above, the modern Angular components, and the Playwright drift-detection spec all derive from.
7.9 Field Inventory Reference (full table)
This table enumerates every user-facing field across the Gift Batch Entry through Posted GL flow as discovered in the legacy source — the ground truth that the mockups above, the modern components, and the Playwright drift-detection spec all derive from. Each row traces to a property on a real legacy ViewModel / typed-dataset row (see evidence column); the validate-storyboard.py gate re-checks every row against the cached legacy source before this section is assembled, so claimed fields cannot drift from what the legacy actually has.
Source profile: dotnet-angularjs · Target framework: Angular 20 + ASP.NET Core 10 (.NET 10) · Screens: 1 (unified workspace) · Top-level fields: 6 · Display-only fields: 3 · Detail-row fields: 10 · Total fields: 19 · Business rules referenced: 8
| Step | Screen | Field id | Label | Kind | Type | Req? | Validation | Conditional | Evidence (legacy) |
|---|---|---|---|---|---|---|---|---|---|
| 1 | UnifiedGiftBatch | a_ledger_number_i | Ledger | lookup | int32 | ✓ | must-exist | AGiftBatchRow.ALedgerNumber | |
| 1 | UnifiedGiftBatch | a_batch_description_c | Batch Description | text-input | string | ✓ | max-length-80 | AGiftBatchRow.BatchDescription | |
| 1 | UnifiedGiftBatch | a_gl_effective_date_d | Effective Date | date-input | date | ✓ | must-fall-in-open-period (BR-GIFT-002) | AGiftBatchRow.GlEffectiveDate | |
| 1 | UnifiedGiftBatch | a_batch_status_c | Batch Status | enum | enum | ✓ | transition-only-via-post-action (BR-GIFT-007) | AGiftBatchRow.BatchStatus | |
| 1 | UnifiedGiftBatch | a_batch_number_i | Batch Number | display | int32 | atomic-increment-on-post (BR-GIFT-001) | AGiftBatchRow.BatchNumber | ||
| 1 | UnifiedGiftBatch | a_currency_code_c | Base Currency | lookup | string | ✓ | must-exist | AGiftBatchRow.CurrencyCode | |
| 1 | UnifiedGiftBatch | summary.transaction_total | Transaction Currency Total | display | decimal | derived: SUM(a_gift_transaction_amount_n) grouped by currency (BR-GIFT-003) | AGiftDetailRow.GiftTransactionAmount | ||
| 1 | UnifiedGiftBatch | summary.base_total | Base Currency Total | display | decimal | derived: SUM(a_gift_amount_n) in ledger base currency (BR-GIFT-003) | AGiftDetailRow.GiftAmount | ||
| 1 | UnifiedGiftBatch | summary.intl_total | International Currency Total | display | decimal | derived: SUM(a_gift_amount_intl_n) (BR-GIFT-003) | AGiftDetailRow.GiftAmountIntl | ||
| 1 | UnifiedGiftBatch › gift_detail_row | p_donor_key_n | Donor | autocomplete | int64 | ✓ | must-exist; partner-class drives motivation hint (BR-GIFT-011) | AGiftRow.DonorKey | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_motivation_group_code_c | Motivation Group | lookup | string | ✓ | auto-assigned-from-partner-class (BR-GIFT-011); user can override | AGiftDetailRow.MotivationGroupCode | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_motivation_detail_code_c | Motivation Detail | lookup | string | ✓ | auto-assigned-from-partner-class (BR-GIFT-011); user can override | AGiftDetailRow.MotivationDetailCode | |
| 1 | UnifiedGiftBatch › gift_detail_row | p_recipient_key_n | Recipient (Partner) | autocomplete | int64 | optional; defaults to recipient_ledger_number if motivation is field-type | visible only when motivation group requires explicit recipient (UNIT/FIELD or UNIT/KEYMIN) | AGiftDetailRow.RecipientKey | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_gift_transaction_amount_n | Amount (Transaction Currency) | number-input | decimal | ✓ | positive-numeric; NUMERIC(12,2) precision | AGiftDetailRow.GiftTransactionAmount | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_transaction_currency_c | Transaction Currency | lookup | string | ✓ | must-exist (a_currency); drives BR-GIFT-003 | AGiftRow.CurrencyCode | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_tax_deductible_pct_n | Tax Deductible % | number-input | decimal | ✓ | 0-to-100-clamped (BR-GIFT-009) | AGiftDetailRow.TaxDeductiblePct | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_print_receipt_l | Print Receipt? | checkbox | boolean | ✓ | default-true; drives BR-GIFT-004 eligibility | AGiftRow.PrintReceipt | |
| 1 | UnifiedGiftBatch › gift_detail_row | a_confidential_gift_flag_l | Confidential Gift | checkbox | boolean | default-false; drives BR-GIFT-006 authorization policy on read | AGiftRow.ConfidentialGiftFlag | ||
| 1 | UnifiedGiftBatch › gift_detail_row | a_modified_detail_l | Modified Detail Flag | display | boolean | system-managed; baseline = 0 on insert (BR-GIFT-008); set to 1 only by adjustment workflow | AGiftDetailRow.ModifiedDetail |
Business rules referenced across these fields: BR-GIFT-001 Gift Batch Sequential Numbering, BR-GIFT-002 Gift Batch Financial Period Validation, BR-GIFT-003 Multi-Currency Gift Processing, BR-GIFT-004 Donation Receipt Generation Workflow, BR-GIFT-006 Confidential Gift Privacy Handling, BR-GIFT-007 Gift Batch Posting Status Constraint, BR-GIFT-008 Unmodified Detail Filter, BR-GIFT-009 Tax Deductible Percentage Bounds, BR-GIFT-010 Tax Deductibility Compliance Calculation, BR-GIFT-011 Motivation Detail Assignment by Partner Class.
This table is target-framework-agnostic. The same inventory feeds the Angular 20 form schema, the ASP.NET Core 10 DTO validators, the EF Core entity column mappings, and the Playwright drift-detection spec. When the legacy a_gift_batch / a_gift / a_gift_detail typed-dataset definitions change — whether by an OpenPetra upstream upgrade or a local schema migration — regenerating the storyboard updates every downstream artifact in lockstep.
8. Code Translation Examples
The legacy Petra server is a .NET Framework 4.7 / Mono application that exposes Finance — Gift Processing through ASMX SOAP web services (notably TGiftTransactionWebConnector). State lives in petra-XML–generated typed DataSets; database access goes through a hand-rolled DBAccess facade that issues string-concatenated SQL against PostgreSQL, MySQL, or SQLite via the TDBType abstraction. Server-side validation is implicit in business-logic classes; logging is TLogging.Log() with severity levels; error handling is throw new EFinanceException("…") caught at the SOAP boundary; observability is essentially absent.
The target Petra server is an ASP.NET Core 10 Minimal API. Entities live in EF Core 10 (Npgsql), persisted to Azure Database for PostgreSQL Flexible Server. Validation is FluentValidation at every API boundary. Outbound calls to the legacy Partner and GL services are wrapped in Polly resilience pipelines (retry + circuit breaker). Logging is Serilog in JSON. Distributed tracing is OpenTelemetry .NET SDK, exporting OTLP to Azure Application Insights. Errors surface as RFC 7807 Problem Details. The same C# language flows through both ends — the modernization win is shape, not syntax.
8.1 Translation Principles
Seven concrete translation patterns appear in the examples below. Read together they describe the full transformation:
| # | Legacy Shape | Modern Shape | Rule fingerprint |
|---|---|---|---|
| 1 | ASMX SOAP [WebMethod] with out parameters and side-effecting boolean returns | ASP.NET Core Minimal-API app.MapPost(…) with a typed request DTO and a typed result record | BR-GIFT-001 / 002 (atomic ++) |
| 2 | petra-XML–generated AGiftBatchTable typed DataSet, mutated in memory | EF Core 10 entity (GiftBatch) with concurrency token, persisted via DbContext | BR-GIFT-001 |
| 3 | Static utility class TFinancialYear.GetLedgerDatePostingPeriod | Injected IFinancialPeriodValidator with FluentValidation + DI | BR-GIFT-002 |
| 4 | Static helper TaxDeductibility.UpdateTaxDeductibiltyAmounts(ref AGiftDetailRow) with Math.Max/Math.Min clamping | Domain method on the GiftDetail aggregate, same clamping, with FluentValidation rule co-located | BR-GIFT-009 / 010 |
| 5 | Switch-case on PartnerClass + UnitTypeCode with string constants | Switch expression on strongly-typed PartnerClass and UnitType enums returning a MotivationAssignment record | BR-GIFT-011 |
| 6 | Raw concatenated SQL with three flags: a_batch_status_c = 'Posted' AND a_modified_detail_l = 0 AND a_print_receipt_l = 1 | EF Core IQueryable<GiftDetail> with global query filter + specification pattern | BR-GIFT-004 / 007 / 008 |
| 7 | Static HttpWebRequest calls to legacy services with hand-rolled retry | HttpClient registered with Polly resilience pipeline (3 retries, exponential backoff, circuit breaker) | BR-GIFT-007 (posting bridge) |
Where the legacy code carries implicit behavior (the SOAP layer caught exceptions and translated them into SOAP faults; the typed DataSet held cross-screen state in memory; SQL strings encoded confidentiality via aliasing), the modernized code makes it explicit: FluentValidation owns input checks, RFC 7807 owns error shape, Polly owns retry, OpenTelemetry owns trace context. The eight catalog rules that fire in the canonical Gift-Batch-Entry use case map directly onto these seven patterns.
8.2 ASMX SOAP Endpoint → Minimal-API Post-Batch (Example 1)
The most architecturally important endpoint in the subsystem is PostGiftBatch — the modernized version of this single method is the strangler-fig routing key (per Section 11). Legacy clients invoke it as a SOAP operation on TGiftTransactionWebConnector; the modernized clients hit POST /api/gift-processing/v1/batches/{batchId}/post.
Legacy: ASMX SOAP (.NET 4.7 / Mono)
// csharp/ICT/Petra/Server/lib/MFinance/
// Gift/Gift.Batch.cs
[WebMethod]
[RequireModulePermission("FINANCE-2")]
public bool PostGiftBatch(
Int32 ALedgerNumber,
Int32 ABatchNumber,
out TVerificationResultCollection
AVerifications)
{
AVerifications = new
TVerificationResultCollection();
TDBTransaction Tx = null;
bool ok = false;
DBAccess.GetDBAccessObj(null)
.WriteTransaction(
ref Tx, ref ok,
delegate
{
var batchTbl = AGiftBatchAccess
.LoadByPrimaryKey(
ALedgerNumber,
ABatchNumber, Tx);
if (batchTbl.Rows.Count == 0)
throw new EFinanceException(
"Batch not found");
var row = (AGiftBatchRow)
batchTbl.Rows[0];
if (row.BatchStatus != "Unposted")
throw new EFinanceException(
"Batch not Unposted");
row.BatchStatus = "Posted";
AGiftBatchAccess.SubmitChanges(
batchTbl, Tx, AVerifications);
TGLPosting.PostBatch(
ALedgerNumber, ABatchNumber,
Tx);
ok = true;
});
return ok;
}
Modern: Minimal API (.NET 10)
// src/Petra.GiftProcessing/Api/
// GiftBatchEndpoints.cs
public static class GiftBatchEndpoints
{
public static void MapGiftBatchPost(
this IEndpointRouteBuilder app)
{
app.MapPost(
"/api/gift-processing/v1/batches/" +
"{batchId:int}/post",
async (
int batchId,
PostBatchCommand cmd,
IValidator<PostBatchCommand> v,
IGiftBatchPoster poster,
ILogger<Program> log,
CancellationToken ct) =>
{
using var act = Tracing.Source
.StartActivity("post_batch");
act?.SetTag("batch_id", batchId);
var vr = await v.ValidateAsync(
cmd, ct);
if (!vr.IsValid)
return Results.ValidationProblem(
vr.ToDictionary());
var r = await poster
.PostAsync(batchId, cmd, ct);
log.LogInformation(
"gift.batch.posted {BatchId} " +
"{JournalRef}",
batchId, r.GlJournalRef);
return Results.Ok(r);
})
.RequireAuthorization(
"Finance.WriteGifts")
.WithName("PostGiftBatch")
.WithOpenApi();
}
}
Translation Notes
- BR-GIFT-007 (Posting Status Constraint) preserved — the
Unposted → Postedtransition now lives inIGiftBatchPoster.PostAsync, enforced by a domain invariant on theGiftBatchaggregate rather than an inline string compare. out TVerificationResultCollectiondisappears — FluentValidation results turn into RFC 7807ValidationProblemresponses at the HTTP boundary.[RequireModulePermission("FINANCE-2")]→.RequireAuthorization("Finance.WriteGifts")(Entra ID JWT policy).- OpenTelemetry span (
"post_batch") replaces the implicit "set status" log line; Serilog structured logging adds{BatchId}+{JournalRef}as queryable fields in Application Insights. - Polly & the GL strangler-bridge call live in
GiftBatchPoster(see Example 7 below); this endpoint stays HTTP-shape only.
8.3 Typed DataSet → EF Core 10 Entity with Atomic Sequence (Example 2)
Petra's legacy data layer is generated from db/petra.xml into typed DataSets — e.g. AGiftBatchTable with strongly-typed columns and a hand-rolled accessor (AGiftBatchAccess). Modernization replaces both with one EF Core entity. The atomic increment behavior of BR-GIFT-001 survives the rewrite: legacy code did ++ALedgerTbl[0].LastGiftBatchNumber inside a serializable transaction; the modern code uses an EF Core concurrency token plus a row-level lock.
Legacy: typed DataSet (.NET 4.7)
// csharp/ICT/Petra/Server/lib/MFinance/
// Gift/Gift.Batch.cs:111
ALedgerTable ALedgerTbl =
ALedgerAccess.LoadByPrimaryKey(
ALedgerNumber, Tx);
if (ALedgerTbl.Rows.Count == 0)
throw new EFinanceException(
"Ledger not found");
// Atomic ++ on the row in memory;
// safety relies on the SQL
// transaction's row lock.
Int32 newBatchNumber =
++ALedgerTbl[0]
.LastGiftBatchNumber;
AGiftBatchRow newBatch =
NewGiftBatchTbl.NewRowTyped();
newBatch.LedgerNumber = ALedgerNumber;
newBatch.BatchNumber = newBatchNumber;
newBatch.BatchStatus = "Unposted";
newBatch.GlEffectiveDate =
ADateEffective;
NewGiftBatchTbl.Rows.Add(newBatch);
ALedgerAccess.SubmitChanges(
ALedgerTbl, Tx, AVerifications);
AGiftBatchAccess.SubmitChanges(
NewGiftBatchTbl, Tx, AVerifications);
Modern: EF Core 10 entity + domain method
// src/Petra.GiftProcessing/Domain/
// GiftBatch.cs + Ledger.cs
public class Ledger
{
public int Id { get; set; }
public int LastGiftBatchNumber
{ get; private set; }
[ConcurrencyCheck]
public uint Xmin { get; private set; }
public int AssignNextBatchNumber()
{
// BR-GIFT-001: monotonic, per-ledger.
// EF Core writes the new value
// back with WHERE xmin = @oldXmin;
// PostgreSQL rejects concurrent
// writers via OCC.
return ++LastGiftBatchNumber;
}
}
// Application service usage
await using var tx = await db.Database
.BeginTransactionAsync(
IsolationLevel.Serializable, ct);
var ledger = await db.Ledgers
.SingleAsync(l => l.Id == ledgerId, ct);
var number = ledger
.AssignNextBatchNumber();
var batch = new GiftBatch
{
LedgerId = ledgerId,
BatchNumber = number,
BatchStatus = BatchStatus.Unposted,
GlEffectiveDate = effectiveDate
};
db.GiftBatches.Add(batch);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
Translation Notes
- BR-GIFT-001 (Gift Batch Sequential Numbering) preserved — per-ledger monotonic, no holes, no collisions. The atomic ++ stays in the domain method; persistence safety moves from "trust the transaction" to "concurrency token + explicit row update."
BatchStatusgoes from string to a strongly-typedBatchStatusenum (Unposted / Posted / Cancelled), enforced at the EF Core value-conversion layer.[ConcurrencyCheck] uint Xminmaps to PostgreSQL's systemxmincolumn — EF Core 10's Npgsql provider supports this directly; conflicting writers get aDbUpdateConcurrencyException.- The legacy "two SubmitChanges in one transaction" idiom collapses into one
SaveChangesAsynccall.
8.4 Static Utility → Injectable Validator (Example 3)
Petra's legacy code uses static-utility classes pervasively — TFinancialYear.GetLedgerDatePostingPeriod is a representative case. The static design makes the helper untestable in isolation and forces every caller to depend on TDBTransaction being threaded through. The modernized version turns it into a DI-registered service backed by FluentValidation for the input rule (BR-GIFT-002: effective date must fall in an open accounting period).
Legacy: static utility + out params
// csharp/ICT/Petra/Server/lib/
// MFinance/Common/Common.FinancialYear.cs
public static class TFinancialYear
{
public static bool
GetLedgerDatePostingPeriod(
Int32 ALedgerNumber,
ref DateTime ADateEffective,
out Int32 ABatchYear,
out Int32 ABatchPeriod,
bool AForceEffectiveDateToFit,
TDBTransaction ATransaction,
bool AShowErrorMessages)
{
ABatchYear = -1;
ABatchPeriod = -1;
var periods = AAccountingPeriodAccess
.LoadViaALedger(
ALedgerNumber, ATransaction);
foreach (AAccountingPeriodRow p
in periods.Rows)
{
if (ADateEffective >=
p.PeriodStartDate &&
ADateEffective <=
p.PeriodEndDate)
{
ABatchYear = p.AccountingYear;
ABatchPeriod = p.AccountingPeriod;
return true;
}
}
if (AForceEffectiveDateToFit)
{
// … clamp to nearest boundary
return true;
}
return false;
}
}
Modern: injected service + FluentValidation
// src/Petra.GiftProcessing/Domain/
// Validation/FinancialPeriodValidator.cs
public interface IFinancialPeriodValidator
{
Task<PeriodResolution> ResolveAsync(
int ledgerId, DateOnly effectiveDate,
bool forceFit, CancellationToken ct);
}
public sealed record PeriodResolution(
int Year, int Period, DateOnly FittedDate);
public class FinancialPeriodValidator
: IFinancialPeriodValidator
{
private readonly GiftDbContext _db;
private readonly ILogger _log;
public FinancialPeriodValidator(
GiftDbContext db,
ILogger<FinancialPeriodValidator> log)
{ _db = db; _log = log; }
public async Task<PeriodResolution>
ResolveAsync(int ledgerId,
DateOnly date, bool forceFit,
CancellationToken ct)
{
var p = await _db.AccountingPeriods
.AsNoTracking()
.Where(x => x.LedgerId == ledgerId)
.Where(x => date >= x.PeriodStart
&& date <= x.PeriodEnd)
.SingleOrDefaultAsync(ct);
if (p is not null)
return new(p.AccountingYear,
p.AccountingPeriod, date);
if (!forceFit)
throw new PeriodOutOfRangeException(
ledgerId, date);
// … nearest-boundary fit
}
}
// FluentValidation rule on the request DTO:
public class CreateBatchValidator
: AbstractValidator<CreateBatchCommand>
{
public CreateBatchValidator(
IFinancialPeriodValidator periods)
{
RuleFor(c => c.EffectiveDate)
.NotEmpty()
.MustAsync(async (c, d, _) =>
{
try { await periods.ResolveAsync(
c.LedgerId, d, c.ForceFit, _);
return true; }
catch (PeriodOutOfRangeException)
{ return false; }
})
.WithMessage(
"Effective date is outside any " +
"open accounting period.");
}
}
Translation Notes
- BR-GIFT-002 (Financial Period Validation) preserved — same open-period filter and same force-fit option, now expressible in a unit test that injects a fake
GiftDbContext. outparameters become a typed record (PeriodResolution); failure surfaces asPeriodOutOfRangeException, mapped to HTTP 422 by the RFC 7807 problem handler.- FluentValidation rule lives on
CreateBatchCommand; the validator is wired to the endpoint via the Minimal-API pattern shown in 8.2. - Reference data (
AccountingPeriods) is a normal EF CoreDbSet— no separate...Accessaccessor class needed.
8.5 Static Math.Max/Min Clamp → Domain Method (Example 4)
TaxDeductibility.UpdateTaxDeductibiltyAmounts is the focal point of BR-GIFT-009 (Tax Deductible Percentage Bounds) and BR-GIFT-010 (Tax Deductibility Compliance Calculation). The legacy code is a static helper that mutates a ref AGiftDetailRow; the modernized version is a domain method on the GiftDetail aggregate, with the input clamp expressed as a FluentValidation rule co-located with the entity.
Legacy: static helper + ref param
// csharp/ICT/Petra/Shared/lib/MFinance/
// TaxDeductibility.cs:50
public static class TaxDeductibility
{
public static void
UpdateTaxDeductibiltyAmounts(
ref AGiftDetailRow AGiftDetail)
{
if (AGiftDetail.IsTaxDeductiblePctNull())
AGiftDetail.TaxDeductiblePct = 0.0m;
AGiftDetail.TaxDeductiblePct =
Math.Max(
AGiftDetail.TaxDeductiblePct, 0m);
AGiftDetail.TaxDeductiblePct =
Math.Min(
AGiftDetail.TaxDeductiblePct, 100m);
decimal pct =
AGiftDetail.TaxDeductiblePct / 100m;
AGiftDetail.TaxDeductibleAmount =
AGiftDetail.GiftAmount * pct;
AGiftDetail.NonDeductibleAmount =
AGiftDetail.GiftAmount * (1m - pct);
AGiftDetail.TaxDeductibleAmountBase =
AGiftDetail.GiftAmountBase * pct;
AGiftDetail.NonDeductibleAmountBase =
AGiftDetail.GiftAmountBase * (1m - pct);
AGiftDetail.TaxDeductibleAmountIntl =
AGiftDetail.GiftAmountIntl * pct;
AGiftDetail.NonDeductibleAmountIntl =
AGiftDetail.GiftAmountIntl * (1m - pct);
}
}
Modern: domain method + FluentValidation
// src/Petra.GiftProcessing/Domain/
// GiftDetail.cs
public class GiftDetail
{
public decimal GiftAmount { get; set; }
public decimal GiftAmountBase { get; set; }
public decimal GiftAmountIntl { get; set; }
public decimal TaxDeductiblePct
{ get; private set; }
public decimal TaxDeductibleAmount
{ get; private set; }
public decimal NonDeductibleAmount
{ get; private set; }
public decimal TaxDeductibleAmountBase
{ get; private set; }
public decimal NonDeductibleAmountBase
{ get; private set; }
public decimal TaxDeductibleAmountIntl
{ get; private set; }
public decimal NonDeductibleAmountIntl
{ get; private set; }
// BR-GIFT-009 + BR-GIFT-010
public void ApplyTaxDeductiblePct(
decimal pct)
{
// Clamp 0..100 (legacy parity)
var p = Math.Max(0m, Math.Min(100m, pct));
TaxDeductiblePct = p;
var f = p / 100m;
TaxDeductibleAmount =
decimal.Round(GiftAmount * f, 2);
NonDeductibleAmount =
GiftAmount - TaxDeductibleAmount;
TaxDeductibleAmountBase =
decimal.Round(GiftAmountBase * f, 2);
NonDeductibleAmountBase =
GiftAmountBase - TaxDeductibleAmountBase;
TaxDeductibleAmountIntl =
decimal.Round(GiftAmountIntl * f, 2);
NonDeductibleAmountIntl =
GiftAmountIntl - TaxDeductibleAmountIntl;
}
}
// Co-located FluentValidation rule
// (input check; clamp still defensive)
public class GiftDetailValidator
: AbstractValidator<GiftDetailDto>
{
public GiftDetailValidator()
{
RuleFor(d => d.TaxDeductiblePct)
.InclusiveBetween(0m, 100m)
.WithMessage(
"Tax deductible percentage " +
"must be between 0 and 100.");
}
}
Translation Notes
- BR-GIFT-009 (Tax Deductible Percentage Bounds) preserved — same
Math.Max/Math.Minclamp, now inside the aggregate so no caller can bypass it. - BR-GIFT-010 (Tax Deductibility Compliance Calculation) preserved — the three-currency split (transaction, base, intl) is identical; only the host changes.
- Defense-in-depth: FluentValidation rejects out-of-range input at the boundary, AND the domain method clamps defensively — matches legacy belt-and-braces behavior.
decimal.Round(…, 2)is added explicitly so the EF CoreNUMERIC(13,2)column writes a deterministic value (legacy relied onSystem.Data.DataRow's implicit rounding).
8.6 Switch-Case String Constants → Strongly-Typed Switch Expression (Example 5)
TGuiTools.GetMotivationGroupAndDetailForPartner drives BR-GIFT-011 (Motivation Detail Assignment by Partner Class). The legacy implementation is a string-on-string switch tracing p_partner.p_partner_class_c + p_unit.u_unit_type_code_c through hardcoded constants in MPartnerConstants + MFinanceConstants. The modernized version becomes a switch expression on strongly-typed enums returning a typed record.
Legacy: nested if + string compares
// csharp/ICT/Petra/Server/lib/MFinance/
// Gift/Gift.gui.tools.cs:105-173
public static void
GetMotivationGroupAndDetailForPartner(
Int64 APartnerKey,
ref string AMotivationGroup,
ref string AMotivationDetail,
TDBTransaction ATransaction)
{
var pTable = PPartnerAccess
.LoadByPrimaryKey(APartnerKey,
ATransaction);
if (pTable.Rows.Count == 0) return;
var partnerClass =
((PPartnerRow)pTable.Rows[0])
.PartnerClass;
if (partnerClass !=
MPartnerConstants.PARTNERCLASS_UNIT)
return;
// try the linked-detail lookup first
var linked = AMotivationDetailAccess
.LoadViaPPartner(APartnerKey,
ATransaction);
if (linked.Rows.Count > 0)
{
var r = (AMotivationDetailRow)
linked.Rows[0];
if (r.MotivationStatus)
{
AMotivationGroup =
r.MotivationGroupCode;
AMotivationDetail =
r.MotivationDetailCode;
return;
}
}
var unit = PUnitAccess
.LoadByPrimaryKey(APartnerKey,
ATransaction);
string ut = ((PUnitRow)unit.Rows[0])
.UnitTypeCode;
if (ut == MPartnerConstants
.UNIT_TYPE_AREA ||
ut == MPartnerConstants
.UNIT_TYPE_FUND ||
ut == MPartnerConstants
.UNIT_TYPE_FIELD)
{
AMotivationDetail =
MFinanceConstants
.GROUP_DETAIL_FIELD;
}
else if (ut == MPartnerConstants
.UNIT_TYPE_KEYMIN)
{
AMotivationDetail =
MFinanceConstants
.GROUP_DETAIL_KEY_MIN;
}
else
{
AMotivationDetail =
MFinanceConstants
.GROUP_DETAIL_SUPPORT;
}
}
Modern: switch expression + record
// src/Petra.GiftProcessing/Domain/
// Motivation/MotivationResolver.cs
public enum PartnerClass
{ Person, Family, Organisation,
Unit, Bank, Church, Venue }
public enum UnitType
{ Area, Fund, Field, KeyMin, Country,
Conference, Team, WorkingGroup,
Other, Root }
public sealed record MotivationAssignment(
string Group, string Detail);
public interface IMotivationResolver
{
Task<MotivationAssignment?> ResolveAsync(
long partnerKey, CancellationToken ct);
}
public class MotivationResolver
: IMotivationResolver
{
private readonly GiftDbContext _db;
public MotivationResolver(GiftDbContext db)
=> _db = db;
public async Task<MotivationAssignment?>
ResolveAsync(long partnerKey,
CancellationToken ct)
{
var partner = await _db.Partners
.AsNoTracking()
.SingleOrDefaultAsync(
p => p.PartnerKey == partnerKey,
ct);
if (partner is null) return null;
if (partner.PartnerClass !=
PartnerClass.Unit)
return null; // caller-default preserved
// Linked-detail wins if active.
var linked = await _db.MotivationDetails
.AsNoTracking()
.Where(m =>
m.LinkedPartnerKey == partnerKey
&& m.MotivationStatus)
.FirstOrDefaultAsync(ct);
if (linked is not null)
return new(linked.MotivationGroupCode,
linked.MotivationDetailCode);
var unit = await _db.Units
.AsNoTracking()
.SingleAsync(
u => u.PartnerKey == partnerKey, ct);
return unit.UnitType switch
{
UnitType.Area
or UnitType.Fund
or UnitType.Field
=> new("GIFT", "FIELD"),
UnitType.KeyMin
=> new("GIFT", "KEYMIN"),
_ => new("GIFT", "SUPPORT"),
};
}
}
Translation Notes
- BR-GIFT-011 (Motivation Detail Assignment by Partner Class) preserved — the test-corroborated nuance for non-UNIT partners (return null, caller keeps its defaults) is preserved by returning
nullinstead of overwriting. ref stringoutput parameters disappear entirely; the typed record (MotivationAssignment) is value-equatable and unit-testable.- String constants (
UNIT_TYPE_AREA, etc.) are replaced with aUnitTypeenum; EF Core's value converter handles the persistence boundary. - The switch expression is exhaustive at compile time — a new
UnitTypemember triggers a compiler warning if a case is missed (legacy's silent fall-through toSUPPORTwas a known pitfall).
8.7 Concatenated SQL → LINQ Specification (Example 6)
The receipt-eligibility query is the single most-cited SQL in the subsystem — csharp/ICT/Petra/Server/sql/Gift.ReceiptPrinting.GetDonationsOfDonor.sql encodes BR-GIFT-004 (dual-flag receipt eligibility), BR-GIFT-007 (posted only), BR-GIFT-008 (unmodified detail filter), and BR-GIFT-006 (confidential-gift dual partner alias) in one 60-line statement. The modernized version expresses each rule as a separate LINQ specification, so each can be unit-tested independently.
Legacy: hand-built SQL string
-- Gift.ReceiptPrinting
-- .GetDonationsOfDonor.sql
SELECT
g.a_ledger_number_i,
g.a_batch_number_i,
g.a_gift_transaction_number_i,
d.a_detail_number_i,
d.a_gift_amount_n,
d.a_gift_amount_intl_n,
d.a_tax_deductible_pct_n,
destn.p_partner_short_name_c
AS gift_destination_name,
rcp.p_partner_short_name_c
AS recipient_name
FROM a_gift_batch b
INNER JOIN a_gift g
ON g.a_ledger_number_i =
b.a_ledger_number_i
AND g.a_batch_number_i =
b.a_batch_number_i
INNER JOIN a_gift_detail d
ON d.a_ledger_number_i =
g.a_ledger_number_i
AND d.a_batch_number_i =
g.a_batch_number_i
AND d.a_gift_transaction_number_i =
g.a_gift_transaction_number_i
INNER JOIN a_motivation_detail m
ON m.a_ledger_number_i =
d.a_ledger_number_i
AND m.a_motivation_group_code_c =
d.a_motivation_group_code_c
AND m.a_motivation_detail_code_c =
d.a_motivation_detail_code_c
INNER JOIN p_partner destn
ON destn.p_partner_key_n =
d.a_recipient_ledger_number_n
INNER JOIN p_partner rcp
ON rcp.p_partner_key_n =
d.p_recipient_key_n
WHERE b.a_batch_status_c = 'Posted'
AND g.a_print_receipt_l = TRUE
AND m.a_receipt_l = TRUE
AND d.a_modified_detail_l = FALSE
AND g.p_donor_key_n = {ADonorKey}
AND g.a_date_entered_d BETWEEN
'{ADateFrom}' AND '{ADateTo}'
ORDER BY g.a_date_entered_d ASC,
d.a_detail_number_i DESC;
Modern: EF Core specifications
// src/Petra.GiftProcessing/Domain/
// Specifications/GiftDetailSpecs.cs
public static class GiftDetailSpecs
{
// BR-GIFT-007
public static IQueryable<GiftDetail> Posted(
this IQueryable<GiftDetail> q)
=> q.Where(d =>
d.Gift.Batch.BatchStatus
== BatchStatus.Posted);
// BR-GIFT-008
public static IQueryable<GiftDetail>
Unmodified(this IQueryable<GiftDetail> q)
=> q.Where(d => !d.ModifiedDetail);
// BR-GIFT-004 (dual flag)
public static IQueryable<GiftDetail>
ReceiptEligible(
this IQueryable<GiftDetail> q)
=> q.Where(d =>
d.Gift.PrintReceipt
&& d.MotivationDetail.ReceiptL);
public static IQueryable<GiftDetail>
ForDonor(this IQueryable<GiftDetail> q,
long donorKey)
=> q.Where(d =>
d.Gift.DonorKey == donorKey);
public static IQueryable<GiftDetail>
EnteredBetween(
this IQueryable<GiftDetail> q,
DateOnly from, DateOnly to)
=> q.Where(d =>
d.Gift.DateEntered >= from
&& d.Gift.DateEntered <= to);
}
// Usage in the receipt service
var q = _db.GiftDetails.AsNoTracking()
.Posted() // BR-GIFT-007
.Unmodified() // BR-GIFT-008
.ReceiptEligible() // BR-GIFT-004
.ForDonor(donorKey)
.EnteredBetween(from, to)
.Include(d => d.GiftDestination)
.Include(d => d.Recipient) // BR-GIFT-006
.OrderBy(d => d.Gift.DateEntered)
.ThenByDescending(d => d.DetailNumber);
// BR-GIFT-006: authorization filter
// applied at the projection step
var dtos = await q
.Select(d => new GiftDetailDto(
d.Id, d.GiftAmount,
d.GiftAmountIntl,
d.TaxDeductiblePct,
d.GiftDestination.ShortName,
user.CanSeeConfidential
|| !d.Gift.ConfidentialGiftFlag
? d.Recipient.ShortName
: "[Confidential]"))
.ToListAsync(ct);
Translation Notes
- BR-GIFT-004 (Donation Receipt Generation Workflow) preserved — dual-flag predicate stays exactly:
PrintReceipt && ReceiptL. - BR-GIFT-007 (Posting Status Constraint) preserved —
BatchStatus.Postedenum compare replaces the string. - BR-GIFT-008 (Unmodified Detail Filter) preserved as the
!ModifiedDetailspecification. - BR-GIFT-006 (Confidential Gift Privacy Handling) is the only rule whose semantics change — previously implicit in the SQL self-join, now an explicit projection-time authorization gate (the mitigation called out in the catalog). The recipient name is replaced with
"[Confidential]"when the caller lacks theGifts.ViewConfidentialpolicy claim. - Each specification is a single-line extension method — unit-tested in isolation against an in-memory
DbContext. - SQL injection vector eliminated —
{ADonorKey}string interpolation is replaced with EF Core parameterization.
8.8 Hand-Rolled HttpWebRequest → Polly Resilience Pipeline (Example 7)
The strangler-fig bridge calls out to the legacy GL service (one-way posting) and the legacy Partner service (read-only validation) over REST. Legacy code wraps these calls in HttpWebRequest with hand-rolled try/catch and no retry; the modernized code uses HttpClient registered with a Polly resilience pipeline (retry + circuit breaker + timeout) and OpenTelemetry HTTP-client instrumentation.
Legacy: HttpWebRequest + try/catch
// csharp/ICT/Petra/Server/lib/
// MFinance/GL/Gl.PostBatch.cs (sketch)
public static bool PostBatchToLegacyGL(
Int32 ALedgerNumber,
Int32 ABatchNumber,
string APayload)
{
try
{
var req = (HttpWebRequest)
WebRequest.Create(
TAppSettingsManager.GetValue(
"Server.LegacyGlUrl"));
req.Method = "POST";
req.ContentType =
"application/x-www-form-urlencoded";
req.Timeout = 30000;
using (var s = req.GetRequestStream())
{
var bytes = Encoding.UTF8
.GetBytes(APayload);
s.Write(bytes, 0, bytes.Length);
}
using (var resp = (HttpWebResponse)
req.GetResponse())
{
return resp.StatusCode ==
HttpStatusCode.OK;
}
}
catch (WebException ex)
{
TLogging.Log(
"GL POST failed: " + ex.Message);
return false;
}
catch (Exception ex)
{
TLogging.Log(
"GL POST unexpected: " + ex.Message);
return false;
}
}
Modern: HttpClient + Polly + OTEL
// src/Petra.GiftProcessing/Program.cs
// HttpClient registration with Polly v8
builder.Services
.AddHttpClient<ILegacyGlClient,
LegacyGlClient>(c =>
{
c.BaseAddress = new Uri(
builder.Configuration[
"LegacyGl:BaseUrl"]!);
c.Timeout = TimeSpan.FromSeconds(15);
})
.AddStandardResilienceHandler(o =>
{
o.Retry.MaxRetryAttempts = 3;
o.Retry.BackoffType =
DelayBackoffType.Exponential;
o.Retry.UseJitter = true;
o.CircuitBreaker
.FailureRatio = 0.5;
o.CircuitBreaker
.SamplingDuration =
TimeSpan.FromSeconds(30);
o.AttemptTimeout.Timeout =
TimeSpan.FromSeconds(10);
});
// src/Petra.GiftProcessing/Bridges/
// LegacyGlClient.cs
public class LegacyGlClient : ILegacyGlClient
{
private readonly HttpClient _http;
private readonly ILogger _log;
private static readonly ActivitySource
_act = new("Petra.LegacyGl");
public LegacyGlClient(
HttpClient http,
ILogger<LegacyGlClient> log)
{ _http = http; _log = log; }
public async Task<GlPostingResult>
PostBatchAsync(
GlPostingPayload payload,
CancellationToken ct)
{
using var span = _act.StartActivity(
"legacy_gl.post_batch");
span?.SetTag("ledger_id",
payload.LedgerId);
span?.SetTag("batch_id",
payload.BatchId);
try
{
var resp = await _http
.PostAsJsonAsync(
"/api/legacy/gl/post",
payload, ct);
resp.EnsureSuccessStatusCode();
var r = await resp.Content
.ReadFromJsonAsync<
GlPostingResult>(
cancellationToken: ct);
_log.LogInformation(
"legacy_gl.posted {LedgerId} " +
"{BatchId} {JournalRef}",
payload.LedgerId,
payload.BatchId,
r!.JournalRef);
return r;
}
catch (Exception ex)
{
span?.SetStatus(
ActivityStatusCode.Error,
ex.Message);
throw new LegacyGlPostingException(
payload.LedgerId,
payload.BatchId, ex);
}
}
}
Translation Notes
- BR-GIFT-007 (Posting Status Constraint) is what triggers this call — the modernized GL post path is the strangler-fig bridge described in Section 11.
- Polly v8
AddStandardResilienceHandlerregisters retry + circuit breaker + per-attempt timeout in one block; legacy "swallow exception, return false" pattern goes away. - Failure surfaces as a domain exception (
LegacyGlPostingException), which the RFC 7807 handler maps to HTTP 502 with a problem document. - OpenTelemetry HTTP-client instrumentation auto-propagates the trace context; the legacy GL service sees a
traceparentheader even though it itself does not emit spans. - Serilog structured logging adds
{LedgerId},{BatchId},{JournalRef}as queryable fields in Application Insights — legacy strings-in-logs were unqueryable.
8.9 Error Handling: SOAP Fault → RFC 7807 Problem Details
The legacy SOAP layer caught EFinanceException at the boundary and serialized it as a SOAP fault with an arbitrary string. The modernized error pipeline turns domain exceptions into RFC 7807 Problem-Details JSON documents, with a stable type URI, a typed status code, and an Application-Insights-queryable correlation ID.
Domain exception & problem-details handler
// Domain exception (BR-GIFT-002 violation)
public sealed class PeriodOutOfRangeException
: Exception
{
public int LedgerId { get; }
public DateOnly EffectiveDate { get; }
public PeriodOutOfRangeException(
int ledgerId, DateOnly date)
: base($"Date {date} is outside any " +
$"open period for ledger " +
$"{ledgerId}.")
{ LedgerId = ledgerId;
EffectiveDate = date; }
}
// Program.cs: ProblemDetails registration
builder.Services
.AddProblemDetails(o =>
{
o.CustomizeProblemDetails = ctx =>
{
var act = Activity.Current;
ctx.ProblemDetails.Extensions[
"traceId"] = act?.TraceId.ToString();
};
});
builder.Services
.AddExceptionHandler<GiftExceptionHandler>();
// GiftExceptionHandler.cs
public class GiftExceptionHandler
: IExceptionHandler
{
public async ValueTask<bool>
TryHandleAsync(HttpContext ctx,
Exception ex, CancellationToken ct)
{
var pd = ex switch
{
PeriodOutOfRangeException p =>
new ProblemDetails
{
Type = "https://petra.openpetra" +
".org/problems/period-out-of-" +
"range",
Title = "Effective date outside " +
"open period",
Status =
StatusCodes.Status422Unprocessable
Entity,
Detail = p.Message
},
LegacyGlPostingException g =>
new ProblemDetails
{
Type = "https://petra.openpetra" +
".org/problems/legacy-gl-down",
Title = "Legacy GL service " +
"unavailable",
Status =
StatusCodes.Status502BadGateway,
Detail = g.Message
},
_ => (ProblemDetails?)null
};
if (pd is null) return false;
ctx.Response.StatusCode =
pd.Status ?? 500;
await ctx.Response.WriteAsJsonAsync(
pd, ct);
return true;
}
}
Translation Notes
- Every business rule that can fail at the API boundary has a dedicated domain exception (
PeriodOutOfRangeException↔ BR-GIFT-002;BatchAlreadyPostedException↔ BR-GIFT-007;DetailAlreadyAdjustedException↔ BR-GIFT-008;LegacyGlPostingException↔ bridge to legacy GL). - The handler maps each domain exception to a stable problem-type URI — clients (Angular SPA, Receipt Generation Service) can switch on
typeinstead of parsing strings. traceIdfromActivity.Currentis added to every problem document — one click in Application Insights opens the failing request's full trace.- The legacy "return false on error" anti-pattern is structurally impossible — all failures surface as exceptions that the pipeline translates.
8.10 Summary
Same language. Different shape. The seven translation examples above describe the entire transformation: SOAP boundary becomes Minimal-API endpoint, typed DataSets become EF Core 10 entities, static utilities become DI services, hand-rolled SQL becomes LINQ specifications, untyped string-status filters become typed enums, raw HttpWebRequest becomes HttpClient + Polly, ad-hoc exceptions become RFC 7807 documents. Every business rule from the Section 9 catalog has at least one translation example anchoring it (BR-GIFT-001 in 8.3, BR-GIFT-002 in 8.4 + 8.9, BR-GIFT-003 in 8.5, BR-GIFT-004/006/007/008 in 8.7, BR-GIFT-007 in 8.2 + 8.8, BR-GIFT-009/010 in 8.5, BR-GIFT-011 in 8.6). The schemas these examples write to are detailed in Section 10; the runtime that hosts them is detailed in Appendix C.
9. Business Rules Analysis
This section documents the behavioral rules extracted from the Petra Finance — Gift Processing subsystem (279 files, 23-cycle Concho analysis), showing C# legacy source implementation alongside C# .NET 10 translations with formal Given-When-Then specifications. All 14 rules were discovered through Concho's Context Graph analysis and verified against source code; 5 are additionally corroborated by surviving OpenPetra NUnit test files (`csharp/ICT/Testing/lib/MFinance/server/Gift/*.test.cs`). A companion subsection — Section 9.8: Field-Level Business Rules — extends this catalog with per-field validation and formatting rules auto-derived from the UI field inventory; those field-level rules feed directly into Playwright test generation for the artifact workflow.
9.1 Business Rules Overview
Concho's codebase intelligence identified 14 behavioral rules governing the Finance — Gift Processing subsystem across 10 verified source files. These rules span five categories: validation (4 rules), calculation (3), state transition (2), workflow (3), and authorization (2). Confidence scores range from 0.70 to 0.95, with 9 rules at or above the 0.75 high-confidence threshold; the remaining 5 (confidence 0.70) are included because they represent important gift-processing behaviors verified directly against source. Project-level context: the Concho cycle-23 context graph catalogs 215 business rules across the full Petra codebase, of which these 14 are the subset tied to the Gift Processing bounded context.
Since the legacy and target platforms share the same language (C#), the majority of rules transfer with minimal behavioral change. The primary modernization impact is architectural: static utility classes become injectable services, typed datasets become EF Core entities, raw SQL becomes parameterized LINQ queries, runtime ALTER TABLE upgrades become EF Core migrations, and XML report templates become a typed .NET reporting service. One rule (BR-GIFT-002, period validation) is a deliberate improvement — the modern workspace warns live instead of silently force-fitting the date — and one rule (BR-GIFT-006) requires explicit mitigation because confidentiality was previously an emergent property of the SQL query layer.
Rule Distribution by Category
| Category | Count | Description | Example Rule |
|---|---|---|---|
| Validation | 4 | Input validation, data-integrity checks, format enforcement | BR-GIFT-001: Gift Batch Sequential Numbering (0.95) |
| Calculation | 3 | Tax deductibility, currency conversion, motivation assignment | BR-GIFT-003: Multi-Currency Gift Processing (0.83) |
| State Transition | 2 | Batch-posting status gates, modification-tracking flags | BR-GIFT-007: Posting Status Constraint (0.90) |
| Workflow | 3 | Receipt generation, recurring-donation processing, confidential gift handling | BR-GIFT-004: Donation Receipt Generation (0.87) |
| Authorization | 2 | Financial report filtering, SEPA mandate compliance | BR-GIFT-013: SEPA Mandate Reference Format (0.89) |
Rule Discovery Methodology
Rules were discovered through Concho's Context Graph, which analyzes code structure, control flow, data dependencies, and naming conventions to identify business-significant logic patterns. Concho provided rule descriptions, confidence scores, evidence classifications (all 14 are STRUCTURAL), and source-file references with line numbers. The workflow then assigned BR-GIFT-NNN identifiers, derived formal Given-When-Then specifications from Concho's descriptions, and verified each cited source file and code snippet via Concho's get_file_content_in_range API.
Seven rules originated as business_rule entities and seven as implementation_constraint entities in Concho's domain model. Both categories encode enforceable behavioral contracts that must be preserved during modernization, so they are treated uniformly in this analysis. After source verification, the workflow ran legacy-test discovery against the OpenPetra `Testing/lib/MFinance/server/Gift/` directory and found 7 surviving NUnit test files; 5 of the 14 rules carry test corroboration as a result, and those rules have their GWT badged accordingly.
GWT Origin Distribution
Of the 14 rules, 5 are code-inferred-test-corroborated (badged orange below) — the code-inferred GWT was within the ≥0.85 agreement threshold of the test-derived GWT, so the two sources are recorded as agreeing. The remaining 9 are code-inferred (badged grey) because no surviving test exercises the rule directly. No rule landed in legacy-test-derived (which would require a 0.5–0.85 agreement band) or conflict-halt (which would block emission). One nuance worth calling out: for a non-UNIT partner the rule leaves the operator's motivation unchanged — and in the source that behavior is the *absence* of an action (a fall-through that writes nothing), which reads ambiguously on its own. The surviving legacy test states the intent explicitly, asserting the unchanged outcome directly, so BR-GIFT-011 is sharpened to exactly the behavior the original developers pinned down. That is the value of reading the legacy tests alongside the code: they record the original team's intent on the edge and do-nothing cases that source alone leaves implicit.
9.2 Validation Rules
BR-GIFT-001: Gift Batch Sequential Numbering
Legacy C# (Gift.Batch.cs)
NewRow = AMainDS.AGiftBatch
.NewRowTyped(true);
NewRow.LedgerNumber = ALedgerNumber;
NewRow.BatchNumber =
++ALedgerTbl[0].LastGiftBatchNumber;
Int32 BatchYear, BatchPeriod;
TFinancialYear.GetLedgerDatePostingPeriod(
ALedgerNumber,
ref ADateEffective,
out BatchYear,
out BatchPeriod,
ATransaction,
AForceEffectiveDateToFit);
NewRow.BatchYear = BatchYear;
NewRow.BatchPeriod = BatchPeriod;
NewRow.GlEffectiveDate = ADateEffective;
NewRow.ExchangeRateToBase = 1.0M;
NewRow.BatchDescription =
"PLEASE ENTER A DESCRIPTION";
NewRow.BankAccountCode =
info.GetDefaultBankAccount();
NewRow.BankCostCentre =
info.GetStandardCostCentre();
NewRow.CurrencyCode =
ALedgerTbl[0].BaseCurrency;
C# .NET 10 (GiftBatchService.cs)
public async Task<GiftBatch> CreateBatchAsync(
int ledgerNumber,
DateTime dateEffective,
bool forceEffectiveDateToFit,
CancellationToken ct = default)
{
await using var tx = await
_db.Database.BeginTransactionAsync(ct);
var ledger = await _db.Ledgers
.Where(l => l.LedgerNumber == ledgerNumber)
.SingleAsync(ct);
// Atomic increment — same sequential
// numbering as legacy ++LastGiftBatchNumber
ledger.LastGiftBatchNumber++;
var (year, period) = await _periodValidator
.GetPostingPeriodAsync(
ledgerNumber, dateEffective,
forceEffectiveDateToFit, ct);
var batch = new GiftBatch
{
LedgerNumber = ledgerNumber,
BatchNumber = ledger.LastGiftBatchNumber,
BatchYear = year,
BatchPeriod = period,
GlEffectiveDate = dateEffective,
ExchangeRateToBase = 1.0m,
CurrencyCode = ledger.BaseCurrency
};
_db.GiftBatches.Add(batch);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return batch;
}
Business Rule Analysis
Code + test agree
Given: A ledger already has a last-used batch number
When: A clerk starts a new gift batch on that ledger
Then: The new batch takes the next number in sequence, so batch numbers always climb and no two batches in a ledger ever share one
Source: Gift.Batch.cs:111
Confidence: 0.95
Legacy Test Coverage:
PostGiftBatch.test.cs:75-130— "TestPostGiftBatch" assertsBatchNumber != -1after import
Behavioral Fidelity: The sequential numbering is preserved exactly. The one change is a safeguard: the next-number step now runs inside a database transaction, so two clerks starting a batch at the same instant can't accidentally grab the same number — protection the legacy in-memory increment didn't have.
BR-GIFT-002: Gift Batch Financial Period Validation
Legacy C# (Gift.Batch.cs)
Int32 BatchYear, BatchPeriod;
// if DateEffective is outside the range of
// open periods, use the most fitting date
TFinancialYear.GetLedgerDatePostingPeriod(
ALedgerNumber,
ref ADateEffective,
out BatchYear,
out BatchPeriod,
ATransaction,
AForceEffectiveDateToFit);
NewRow.BatchYear = BatchYear;
NewRow.BatchPeriod = BatchPeriod;
NewRow.GlEffectiveDate = ADateEffective;
C# .NET 10 (FinancialPeriodValidator.cs)
public interface IFinancialPeriodValidator
{
Task<(int year, int period)> GetPostingPeriodAsync(
int ledgerNumber,
DateTime dateEffective,
bool forceEffectiveDateToFit,
CancellationToken ct);
}
// Service impl uses EF Core query over
// a_accounting_period rows for the ledger
var period = await _db.AccountingPeriods
.Where(p => p.LedgerNumber == ledgerNumber
&& p.PeriodStartDate <= dateEffective
&& p.PeriodEndDate >= dateEffective)
.SingleOrDefaultAsync(ct);
if (period is null && forceEffectiveDateToFit)
{
period = await _db.AccountingPeriods
.Where(p => p.LedgerNumber == ledgerNumber)
.OrderBy(p => Math.Abs(
(p.PeriodStartDate - dateEffective)
.TotalDays))
.FirstAsync(ct);
}
return (period.YearNumber, period.PeriodNumber);
Business Rule Analysis
Code + test agree
Given: A staff member is entering a gift batch and picks the date the gifts should post to
When: The system checks that date against the ledger's open accounting periods — in the modern workspace, live as the date is entered
Then: If the date falls in an open period, the batch is accepted for that period. If it falls outside one, the legacy system silently snapped the date to the nearest open period at save time; the modern workspace instead flags it immediately and asks the operator to choose a valid date themselves — a deliberate behavior improvement (see Fidelity)
Source: Gift.Batch.cs:114
Confidence: 0.90
Legacy Test Coverage:
PostGiftBatch.test.cs:85-130— "TestPostGiftBatch" verifies current-year dates post successfully
Behavioral Fidelity: Deliberate behavior change — a documented improvement, not a verbatim carry-forward. The open-period check is preserved, but the legacy save-time silent force-fit — which could post a gift to a different period than the operator intended, with no signal — is replaced by live, in-line validation: the operator is warned the instant a date falls outside an open period and corrects it deliberately, rather than discovering a silent adjustment after posting. This is one example of the broader batch→real-time shift: work the legacy system deferred to save time now happens as the operator types.
BR-GIFT-009: Tax Deductible Percentage Bounds
Legacy C# (TaxDeductibility.cs)
if (AGiftDetail == null) return;
else if (AGiftDetail.IsTaxDeductiblePctNull())
{
AGiftDetail.TaxDeductiblePct = 0.0m;
}
AGiftDetail.TaxDeductiblePct =
Math.Max(AGiftDetail.TaxDeductiblePct, 0);
AGiftDetail.TaxDeductiblePct =
Math.Min(AGiftDetail.TaxDeductiblePct, 100);
/* Update transaction amounts */
CalculateTaxDeductibilityAmounts(
out TaxDeductAmount,
out NonDeductAmount,
AGiftDetail.GiftTransactionAmount,
AGiftDetail.TaxDeductiblePct);
if (AGiftDetail.IsTaxDeductibleAmountNull()
|| AGiftDetail.IsNonDeductibleAmountNull()
|| (AGiftDetail.TaxDeductibleAmount
!= TaxDeductAmount)
|| (AGiftDetail.NonDeductibleAmount
!= NonDeductAmount))
{
AGiftDetail.TaxDeductibleAmount =
TaxDeductAmount;
AGiftDetail.NonDeductibleAmount =
NonDeductAmount;
}
C# .NET 10 (GiftDetail.cs / TaxDeductibilityService.cs)
public void UpdateTaxDeductibilityAmounts(
GiftDetail detail)
{
// Clamp percentage to [0, 100]
detail.TaxDeductiblePct = Math.Clamp(
detail.TaxDeductiblePct ?? 0m, 0m, 100m);
var (deduct, nonDeduct) =
CalculateAmounts(
detail.GiftTransactionAmount,
detail.TaxDeductiblePct.Value);
if (detail.TaxDeductibleAmount != deduct
|| detail.NonDeductibleAmount != nonDeduct)
{
detail.TaxDeductibleAmount = deduct;
detail.NonDeductibleAmount = nonDeduct;
}
// Repeat for base and intl currency amounts
UpdateBaseAmounts(detail);
UpdateIntlAmounts(detail);
}
Business Rule Analysis
Code-inferred
Given: A gift's tax-deductible percentage is entered, or left blank
When: The system records the deductible split for that gift
Then: A blank is treated as zero, and the value is held to the 0-to-100 range — anything below 0 becomes 0, anything above 100 becomes 100 — and the deductible and non-deductible amounts are recalculated across all three currencies
Source: TaxDeductibility.cs:50
Confidence: 0.89
Legacy Test Coverage: No direct legacy test coverage found (clamping is exercised transitively via RevertAdjustGiftBatch.test.cs).
Behavioral Fidelity: The same bounds apply, unchanged. The only difference is internal — the calculation moves from a shared utility onto the gift itself — and the exact-decimal precision is preserved, with no rounding introduced.
BR-GIFT-014: Barcode Character Validation for Receipts
Legacy C# (BarCode128.cs)
foreach (char c in AText)
{
if (((c < 32) || (c > 126))
&& (c != 203))
{
// invalid character
return "";
}
}
bool tableB = true;
int ind = 0;
int length = AText.Length;
int checksum = 0;
// ... checksum computation follows ...
C# .NET 10 (BarcodeEncoderService.cs)
public sealed class BarcodeEncoderService
: IBarcodeEncoder
{
public string EncodeCode128(string text)
{
if (string.IsNullOrEmpty(text))
return string.Empty;
// Same ASCII range check as legacy:
// 32..126 OR 203
foreach (char c in text)
{
if ((c < 32 || c > 126) && c != 203)
return string.Empty;
}
return ComputeChecksumAndEncode(text);
}
}
Business Rule Analysis
Code-inferred
Given: Text is sent to be turned into a Code 128 barcode for a receipt
When: The barcode is generated
Then: Every character is checked against the range Code 128 allows; if any character is out of range, no barcode is produced; otherwise the barcode is generated with its proper checksum
Source: BarCode128.cs:51
Confidence: 0.80
Legacy Test Coverage: No legacy test coverage found (Common/Printing tests cover PDF assembly but not Code 128 character bounds).
Behavioral Fidelity: The same character-range check is preserved unchanged. Internally the barcode generator becomes a swappable service, leaving room to configure it later without touching the validation rule.
9.3 Calculation Rules
BR-GIFT-003: Multi-Currency Gift Processing
Legacy SQL (HOSAReportGiftSummary.sql)
-- Three-currency amount projection
SELECT
a_gift_transaction_amount_n AS TxAmount,
a_gift_amount_n AS BaseAmount,
a_gift_amount_intl_n AS IntlAmount,
a_tax_deductible_amount_n,
a_tax_deductible_amount_base_n,
a_non_deductible_amount_n,
a_non_deductible_amount_base_n,
a_gift_batch.a_currency_code_c AS Currency
FROM a_gift_batch,
a_gift,
a_gift_detail,
a_motivation_detail
WHERE GiftDetail.a_ledger_number_i = ?
AND a_gift_batch.a_batch_status_c
= 'Posted'
GROUP BY a_recipient_key_n,
a_currency_code_c
C# .NET 10 (GiftReportService.cs)
// Entity preserves three currency amounts
public class GiftDetail
{
public decimal GiftTransactionAmount { get; set; }
public decimal GiftAmount { get; set; } // base
public decimal GiftAmountIntl { get; set; }
public decimal TaxDeductibleAmount { get; set; }
public decimal TaxDeductibleAmountBase { get; set; }
public decimal NonDeductibleAmount { get; set; }
public decimal NonDeductibleAmountBase { get; set; }
}
// LINQ aggregation by recipient + currency
var hosaTotals = _db.GiftDetails
.Include(d => d.GiftBatch)
.Where(d => d.GiftBatch.LedgerNumber == ledgerId
&& d.GiftBatch.BatchStatus == BatchStatus.Posted)
.GroupBy(d => new {
d.RecipientKey,
d.GiftBatch.CurrencyCode })
.Select(g => new HosaSummary
{
RecipientKey = g.Key.RecipientKey,
Currency = g.Key.CurrencyCode,
TotalTransaction = g.Sum(d =>
d.GiftTransactionAmount),
TotalBase = g.Sum(d => d.GiftAmount),
TotalIntl = g.Sum(d => d.GiftAmountIntl)
});
Business Rule Analysis
Code-inferred
Given: A gift comes in a currency other than the ledger's base currency (here, something other than EUR)
When: The gift is saved, or a receipt or donor-summary report is produced
Then: The system keeps three amounts side by side — the original transaction amount, the base-currency amount, and an international-clearing amount — and tracks the deductible / non-deductible split in both the transaction and base currencies; report totals are grouped by recipient and currency
Source: Gift.ReceiptPrinting.GetDonationsOfDonor.sql:7; ICH.HOSAReportGiftSummary.sql:26
Confidence: 0.83
Legacy Test Coverage: No direct legacy test coverage found (multi-currency totals exercised tangentially via MatchingGiftsFromBankstatement.test.cs CAMT/MT940 imports).
Behavioral Fidelity: The three-currency model carries over unchanged. The only change is internal — the totals are computed by the modern data layer instead of hand-written SQL — and every amount stays in exact decimal money, so nothing rounds differently than it did in legacy.
BR-GIFT-010: Tax Deductibility Compliance Calculation
Legacy C# (Gift.TaxDeductiblePct.cs)
[RequireModulePermission("FINANCE-1")]
public static bool GetGiftsForTaxDeductiblePct
Adjustment(ref GiftBatchTDS AGiftDS,
Int64 ARecipientKey,
DateTime ADateFrom,
decimal ANewPct,
out TVerificationResultCollection AMessages)
{
db.ReadTransaction( ref Transaction,
delegate
{
string Query = "SELECT a_gift_detail.*"
+ " FROM a_gift_detail, a_gift_batch"
+ " WHERE p_recipient_key_n = "
+ ARecipientKey
+ " AND a_tax_deductible_pct_n <> "
+ ANewPct
+ " AND a_modified_detail_l <> true"
+ " AND a_tax_deductible_l = true"
+ " AND a_batch_status_c = 'Posted'"
+ " AND a_gl_effective_date_d >= '"
+ ADateFrom.ToString("yyyy-MM-dd")
+ "'";
db.Select(MainDS, Query,
MainDS.AGiftDetail.TableName,
Transaction);
foreach (Row in MainDS.AGiftDetail.Rows)
{
Row.TaxDeductiblePct = ANewPct;
TaxDeductibility
.UpdateTaxDeductibiltyAmounts(
ref Row);
}
AGiftDetailAccess.SubmitChanges(
Table, Transaction);
});
}
C# .NET 10 (TaxDeductibilityAdjustmentService.cs)
[Authorize(Policy = "Finance.WriteGifts")]
public async Task<AdjustmentResult>
AdjustTaxDeductiblePctAsync(
long recipientKey,
DateTime dateFrom,
decimal newPct,
CancellationToken ct = default)
{
await using var tx = await
_db.Database.BeginTransactionAsync(ct);
// Parameterized query (no SQL injection risk)
var details = await _db.GiftDetails
.Where(d =>
d.RecipientKey == recipientKey
&& d.TaxDeductiblePct != newPct
&& !d.ModifiedDetail
&& d.TaxDeductible
&& d.GiftBatch.BatchStatus
== BatchStatus.Posted
&& d.GiftBatch.GlEffectiveDate
>= dateFrom)
.ToListAsync(ct);
foreach (var detail in details)
{
detail.TaxDeductiblePct = newPct;
_taxService.UpdateTaxDeductibilityAmounts(
detail);
}
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return new AdjustmentResult(details.Count);
}
Business Rule Analysis
Code-inferred
Given: A recipient's tax-deductible percentage has to change — say a regulation shifts
When: An authorized finance user runs the adjustment from a chosen date forward
Then: Every affected gift — already posted, not previously adjusted, tax-deductible, and currently at a different percentage — is pulled, its percentage and amounts are updated, and all the changes commit together as a single transaction
Source: Gift.TaxDeductiblePct.cs:138-197
Confidence: 0.88
Legacy Test Coverage: No direct test coverage found for the tax-pct adjustment pathway.
Behavioral Fidelity: The bulk-adjustment behavior carries over, with a security improvement: legacy built its database query by stitching text together (an injection risk); the modern version uses safe, parameterized queries instead. The finance-only restriction becomes an explicit authorization policy.
BR-GIFT-011: Motivation Detail Assignment by Partner Class
Legacy C# (Gift.gui.tools.cs)
// First check for linked motivation detail
AMotivationDetailTable MotivationDetailTable =
AMotivationDetailAccess.LoadViaPPartner(
APartnerKey, readTransaction);
if (MotivationDetailTable.Rows.Count > 0)
{
foreach (Row in MotivationDetailTable.Rows)
{
if (Row.MotivationStatus)
{
motivationGroup = Row.MotivationGroupCode;
motivationDetail = Row.MotivationDetailCode;
KeyMinFound = true;
break;
}
}
}
if (!KeyMinFound)
{
// Map UnitTypeCode to motivation detail
PUnitTable pUnitTable =
PUnitAccess.LoadByPrimaryKey(
APartnerKey, readTransaction);
switch (pUnitTable[0].UnitTypeCode)
{
case UNIT_TYPE_AREA:
case UNIT_TYPE_FUND:
case UNIT_TYPE_FIELD:
motivationDetail =
GROUP_DETAIL_FIELD;
break;
case UNIT_TYPE_KEYMIN:
motivationDetail =
GROUP_DETAIL_KEY_MIN;
break;
default:
motivationDetail =
GROUP_DETAIL_SUPPORT;
break;
}
}
C# .NET 10 (MotivationResolver.cs)
public async Task<MotivationAssignment?>
ResolveAsync(long partnerKey,
CancellationToken ct = default)
{
var partner = await _db.Partners
.SingleOrDefaultAsync(
p => p.PartnerKey == partnerKey, ct);
if (partner is null) return null;
if (partner.PartnerClass != "UNIT")
// Non-UNIT: leave caller defaults
// unchanged (test-corroborated)
return MotivationAssignment.Unchanged;
// Check for partner-linked motivation
var linked = await _db.MotivationDetails
.Where(m => m.PartnerKey == partnerKey
&& m.MotivationStatus)
.FirstOrDefaultAsync(ct);
if (linked is not null)
return new MotivationAssignment(
linked.GroupCode,
linked.DetailCode);
// Fall back to unit type mapping
var unit = await _db.Units
.SingleAsync(
u => u.PartnerKey == partnerKey, ct);
return unit.UnitTypeCode switch
{
"AREA" or "FUND" or "FIELD"
=> MotivationAssignment.Field,
"KEYMIN"
=> MotivationAssignment.KeyMin,
_ => MotivationAssignment.Support
};
}
Business Rule Analysis
Code + test agree
Given: A gift is being entered for a recipient
When: The system looks at what kind of recipient it is
Then: For a "unit" recipient (a project, field, or ministry), it first uses any motivation specifically linked to that recipient; if there is none, it assigns one from the unit's type — area, fund, and field projects get the Field motivation, key-ministry gets Key Ministry, and everything else gets Support. For any other kind of recipient, it leaves the motivation exactly as the operator entered it, rather than overwriting it (confirmed by the legacy test that checks a person-type recipient comes back unchanged)
Source: Gift.gui.tools.cs:105-173
Confidence: 0.70 (code) / 0.90 (test corroboration)
Legacy Test Coverage:
SetMotivationGroupAndDetail.test.cs:55-180— five-case suite:Test_NullPartner,Test_InvalidPartner,Test_Person,Test_Unit_WithoutKeyMin,Test_Unit_WithKeyMin
Behavioral Fidelity: The three-step resolution — linked motivation first, then the unit's type, otherwise leave it as entered — is preserved exactly. The important nuance the legacy tests confirmed (a non-unit recipient keeps whatever motivation was already entered, rather than being forced to "Support") carries over unchanged.
9.4 State Transition Rules
BR-GIFT-007: Gift Batch Posting Status Constraint
Legacy SQL (Gift.GetGiftsToAdjustField.sql)
SELECT PUB_a_gift_detail.*
FROM PUB_a_gift_batch,
PUB_a_gift,
PUB_a_gift_detail
WHERE PUB_a_gift_batch.a_ledger_number_i = ?
AND PUB_a_gift_batch.a_gl_effective_date_d
>= ?
AND PUB_a_gift_batch.a_gl_effective_date_d
<= ?
AND PUB_a_gift_batch.a_batch_status_c
= 'Posted'
AND PUB_a_gift.a_ledger_number_i
= PUB_a_gift_batch.a_ledger_number_i
AND PUB_a_gift.a_batch_number_i
= PUB_a_gift_batch.a_batch_number_i
AND PUB_a_gift_detail.a_ledger_number_i
= PUB_a_gift_batch.a_ledger_number_i
AND PUB_a_gift_detail.a_batch_number_i
= PUB_a_gift_batch.a_batch_number_i
AND PUB_a_gift_detail.
a_gift_transaction_number_i
= PUB_a_gift.a_gift_transaction_number_i
AND PUB_a_gift_detail.a_modified_detail_l = 0
AND p_recipient_key_n = ?
AND a_recipient_ledger_number_n = ?
C# .NET 10 (GiftQueryService.cs)
// Global query filter applied at model level
modelBuilder.Entity<GiftBatch>()
.HasQueryFilter(b =>
b.BatchStatus == BatchStatus.Posted);
// Caller can opt out with IgnoreQueryFilters()
// for admin scenarios that need draft batches.
public async Task<List<GiftDetail>>
GetDetailsForAdjustmentAsync(
int ledgerNumber,
DateOnly from, DateOnly to,
long recipientKey,
long recipientLedgerNumber,
CancellationToken ct = default)
{
return await _db.GiftDetails
.Include(d => d.Gift)
.Include(d => d.GiftBatch)
.Where(d =>
d.GiftBatch.LedgerNumber == ledgerNumber
&& d.GiftBatch.GlEffectiveDate >= from
&& d.GiftBatch.GlEffectiveDate <= to
&& !d.ModifiedDetail
&& d.RecipientKey == recipientKey
&& d.RecipientLedgerNumber
== recipientLedgerNumber)
.ToListAsync(ct);
// BatchStatus = Posted applied globally
}
Business Rule Analysis
Code + test agree
Given: An adjustment or a financial report runs over gift batches
When: The system selects which batches count
Then: Only posted batches are included — anything still unposted, draft, or cancelled is left out of every financial operation
Source: Gift.GetGiftsToAdjustField.sql:6
Confidence: 0.90
Legacy Test Coverage:
RevertAdjustGiftBatch.test.cs:50-260— "TestAdjustGiftBatch" imports, posts, then adjusts a batch; only posted batches participate
Behavioral Fidelity: The posted-only rule is unchanged. The modern version enforces it in one central place rather than re-stating it in each query, so it can't be forgotten in a query written later; and batch status becomes a fixed set of allowed values instead of a free-text string.
BR-GIFT-008: Unmodified Detail Filter
Legacy SQL (Gift.GetGiftsToAdjustField.sql)
-- Line 11: idempotency guard
AND PUB_a_gift_detail.a_modified_detail_l = 0
C# .NET 10 (AdjustmentSpecification.cs)
public class AdjustmentSpecification
: Specification<GiftDetail>
{
public override Expression<
Func<GiftDetail, bool>>
ToExpression() =>
detail => !detail.ModifiedDetail;
}
// Usage:
.Where(new AdjustmentSpecification()
.ToExpression())
// After adjustment, the flag is set
// to prevent re-processing:
detail.ModifiedDetail = true;
Business Rule Analysis
Code-inferred
Given: An adjustment pulls gift lines to reallocate
When: The system selects which lines to act on
Then: Only lines that haven't already been adjusted are included; once a line is adjusted it's marked, so the same line can't be processed twice
Source: Gift.GetGiftsToAdjustField.sql:11
Confidence: 0.89
Legacy Test Coverage: No direct legacy test coverage found (idempotency exercised transitively via the adjustment cycle in RevertAdjustGiftBatch.test.cs).
Behavioral Fidelity: The same once-only guarantee is preserved. The "not yet adjusted" condition is packaged as a single reusable rule, so it's easy to find and apply consistently wherever it's needed.
9.5 Workflow Rules
BR-GIFT-004: Donation Receipt Generation Workflow
Legacy SQL (GetDonationsOfDonor.sql)
-- Dual-flag eligibility: print + receipt
WHERE a_gift_batch.a_ledger_number_i = ?
AND a_gift_batch.a_batch_status_c = 'Posted'
AND a_gift_batch.a_gl_effective_date_d
BETWEEN ? AND ?
AND a_gift.p_donor_key_n = ?
AND a_gift.a_print_receipt_l = 1
AND a_motivation_detail.a_receipt_l = 1
AND a_gift_detail.a_modified_detail_l = 0
ORDER BY a_gift.a_date_entered_d ASC,
a_gift_detail.a_detail_number_i DESC
C# .NET 10 (ReceiptGenerationService.cs)
public class ReceiptEligibilitySpec
: Specification<GiftDetail>
{
public ReceiptEligibilitySpec(
long donorKey,
DateOnly from, DateOnly to)
{
AddCriteria(d =>
d.Gift.DonorKey == donorKey
&& d.GiftBatch.GlEffectiveDate
>= from
&& d.GiftBatch.GlEffectiveDate
<= to
&& d.GiftBatch.BatchStatus
== BatchStatus.Posted
&& d.Gift.PrintReceipt
&& d.MotivationDetail.Receipt
&& !d.ModifiedDetail);
OrderBy(d => d.Gift.DateEntered);
ThenByDescending(
d => d.DetailNumber);
}
}
// Razor template renders the receipt PDF
var details = await _db.GiftDetails
.Specify(new ReceiptEligibilitySpec(
donorKey, from, to))
.ToListAsync(ct);
var html = await _razor.RenderAsync(
"ReceiptTemplate", new ReceiptModel(donor, details));
var pdf = await _pdfRenderer.RenderAsync(html);
Business Rule Analysis
Code + test agree
Given: A donor's giving history is pulled to print receipts for a date range
When: The receipt run executes
Then: A gift appears on a receipt only when all four hold — it's flagged for a receipt, its motivation is receiptable, its batch is posted, and it hasn't been adjusted; results come back oldest-first
Source: GetDonationsOfDonor.sql:37; methodofgiving.xml:48
Confidence: 0.87
Legacy Test Coverage:
SingleGiftReceipt.test.cs:60-180— "TestSingleReceipt" imports gifts, posts batch, asserts receipt content matches expected fileAnnualReceipts.test.cs:55-170— "TestAnnualReceipt" verifies posted-batch + receipt-flag gating, multi-locale (German)
Behavioral Fidelity: The four-part eligibility test is preserved exactly. Internally the receipt itself moves from legacy HTML-template merging to a modern templating-and-PDF approach — with no change to who is and isn't eligible for a receipt.
BR-GIFT-005: Recurring Donation Frequency Scheduling
Legacy C# (Upgrade202206_202207.cs)
// Schema upgrade: add SEPA columns at runtime
if (!ColumnExists)
{
sql = "ALTER TABLE PUB_a_recurring_gift "
+ "ADD COLUMN a_sepa_mandate_reference_c "
+ "varchar(70) DEFAULT NULL "
+ "AFTER p_banking_details_key_i";
ADataBase.ExecuteNonQuery(
sql, SubmitChangesTransaction);
sql = "ALTER TABLE PUB_a_recurring_gift "
+ "ADD COLUMN a_sepa_mandate_given_d "
+ "date DEFAULT NULL "
+ "AFTER a_sepa_mandate_reference_c";
ADataBase.ExecuteNonQuery(
sql, SubmitChangesTransaction);
}
// Per-donor scheduling config:
// p_partner.a_receipt_letter_frequency_c
// ("Annual"/"Monthly"/...)
// p_partner.a_receipt_each_gift_l
// (true/false)
C# .NET 10 (EF Core Migration + RecurringGiftService.cs)
// EF Core migration replaces runtime ALTER
public partial class AddSepaMandate : Migration
{
protected override void Up(MigrationBuilder mb)
{
mb.AddColumn<string>(
name: "SepaMandateReference",
table: "RecurringGift",
type: "varchar(70)",
nullable: true);
mb.AddColumn<DateOnly?>(
name: "SepaMandateGiven",
table: "RecurringGift",
nullable: true);
}
}
// Background scheduler emits SEPA mandate refs
public class RecurringGiftScheduler
: BackgroundService
{
protected override async Task ExecuteAsync(
CancellationToken ct)
{
// ... process per-donor schedule,
// generate SEPA refs in YYYYMMDD
}
}
Business Rule Analysis
Code + test agree
Given: A donor has a recurring gift set up, with their direct-debit (SEPA) mandate details on file
When: The recurring-gift cycle runs
Then: Each gift is processed on its own schedule, a SEPA mandate reference is generated for it, and donors set to annual receipting get one consolidated year-end receipt instead of a receipt per gift
Source: Upgrade202206_202207.cs:66; GenerateDonors.cs:97
Confidence: 0.70
Legacy Test Coverage:
RecurringGiftBatch.test.cs:1-200— lifecycle tests for recurring batches with donor/recipient/motivation wiring
Behavioral Fidelity: The mandate format and the per-frequency scheduling carry over unchanged. Two internal cleanups: schema changes that legacy applied at runtime become proper versioned migrations, and the recurring cycle can run as a managed background job.
BR-GIFT-006: Confidential Gift Privacy Handling [Mitigation needed]
Legacy SQL (GetDonationsOfDonor.sql)
-- Dual partner alias: destination + recipient
FROM a_gift_batch,
a_gift,
a_gift_detail,
a_motivation_detail,
a_account,
a_cost_centre,
p_partner AS GiftDestination,
p_partner AS Recipient
WHERE
GiftDestination.p_partner_key_n
= a_gift_detail.a_recipient_ledger_number_n
AND Recipient.p_partner_key_n
= a_gift_detail.p_recipient_key_n
-- (confidentiality is implicit:
-- callers must use the right alias
-- in their reports)
C# .NET 10 (GiftDetail.cs + AuthZ policy)
public class GiftDetail
{
// Distinct navigation paths preserve
// the legacy dual-alias intent
public Partner GiftDestination { get; set; }
public Partner Recipient { get; set; }
public bool Confidential { get; set; }
}
// Explicit authorization policy: required to
// view recipient details for confidential gifts
[Authorize(Policy = "Gifts.ViewConfidential")]
public async Task<GiftDetailDto>
GetGiftDetailAsync(int id, ClaimsPrincipal user)
{
var detail = await _db.GiftDetails
.Include(d => d.GiftDestination)
.Include(d => d.Recipient)
.SingleAsync(d => d.Id == id, ct);
// Redact recipient if confidential AND
// caller lacks the policy:
if (detail.Confidential &&
!user.HasClaim("gifts.viewConfidential"))
{
return GiftDetailDto.WithRedactedRecipient(
detail);
}
return GiftDetailDto.From(detail);
}
Business Rule Analysis
Code-inferred
Given: A receipt or report includes a gift to a confidential recipient
When: The system pulls who the gift went to
Then: It distinguishes the organizational destination from the individual recipient, and the confidential individual is shown only to users authorized to see it
Source: GetDonationsOfDonor.sql:27
Confidence: 0.70
Legacy Test Coverage: No legacy test coverage found for confidential-gift visibility.
Behavioral Fidelity: Mitigation needed. The split between destination and individual recipient is preserved, but legacy enforced confidentiality only as a side effect of how its database query happened to be shaped — too implicit to rely on. The modern system must make it an explicit permission check and actively hide the recipient from anyone who lacks that permission.
9.6 Authorization Rules
BR-GIFT-012: Posted-Batch Financial Integrity for Reports
Legacy XML (methodofgiving.xml)
<calculation id="GetGiftBatchCurrencies"
returns="line_a_currency_code_c"
returnsFormat="row">
<query>
<queryDetail><value>
Select Distinct
batch.a_currency_code_c
as line_a_currency_code_c
FROM PUB_a_gift_batch as batch
WHERE batch.a_gl_effective_date_d
BETWEEN {#param_start_date#}
AND {#param_end_date#}
AND batch.a_ledger_number_i
= {{param_ledger_number_i}}
AND batch.a_batch_status_c = 'Posted'
ORDER BY batch.a_currency_code_c
</value></queryDetail>
</query>
</calculation>
C# .NET 10 (MethodOfGivingReportService.cs)
// Typed report parameters replace XML tokens
public record MethodOfGivingParams(
int LedgerNumber,
DateOnly StartDate,
DateOnly EndDate);
public async Task<MethodOfGivingReport>
GenerateAsync(
MethodOfGivingParams p,
CancellationToken ct = default)
{
var batches = _db.GiftBatches
.Where(b =>
b.LedgerNumber == p.LedgerNumber
&& b.GlEffectiveDate >= p.StartDate
&& b.GlEffectiveDate <= p.EndDate);
// BatchStatus=Posted via global filter
var groupedGifts = await batches
.SelectMany(b => b.Gifts)
.SelectMany(g => g.Details)
.Where(d => d.MotivationDetail.Receipt)
.GroupBy(d => new {
d.MotivationGroupCode,
d.GiftBatch.CurrencyCode })
.ToListAsync(ct);
return MethodOfGivingReport.From(groupedGifts);
}
Business Rule Analysis
Code-inferred
Given: A Method-of-Giving financial report is run for a date range
When: The report gathers its data
Then: It counts only posted, receiptable gifts, and groups the totals by method of giving and by currency
Source: methodofgiving.xml:48 (and surrounding query template)
Confidence: 0.86
Legacy Test Coverage: No legacy test coverage found (XML report templates are covered by end-to-end smoke tests out of this slice's scope).
Behavioral Fidelity: The same filtering is preserved. Internally the report moves from an XML template with text-substituted tokens to a modern, typed reporting service — with no change to what's counted.
BR-GIFT-013: SEPA Mandate Reference Format
Legacy C# (Upgrade202206_202207.cs)
// Column DDL: enforces the storage shape
sql = "ALTER TABLE PUB_a_recurring_gift "
+ "ADD COLUMN a_sepa_mandate_reference_c "
+ "varchar(70) DEFAULT NULL "
+ "AFTER p_banking_details_key_i";
ADataBase.ExecuteNonQuery(
sql, SubmitChangesTransaction);
// Format convention enforced by callers:
// {donor_key}{YYYYMMDD}
// e.g. "27000000020220714"
// where 2700000002 = donor partner key
// 20220714 = mandate-given date
C# .NET 10 (SepaMandateReference.cs)
public readonly record struct SepaMandateReference
{
public string Value { get; }
private SepaMandateReference(string value)
=> Value = value;
public static SepaMandateReference Create(
long donorKey,
DateOnly mandateGiven)
{
var s =
$"{donorKey}{mandateGiven:yyyyMMdd}";
if (s.Length > 70)
throw new ArgumentException(
"SEPA mandate exceeds varchar(70)");
return new SepaMandateReference(s);
}
public override string ToString() => Value;
}
// On the EF Core entity:
public class RecurringGift
{
[MaxLength(70)]
public string? SepaMandateReference { get; set; }
public DateOnly? SepaMandateGiven { get; set; }
}
Business Rule Analysis
Code-inferred
Given: A recurring gift is set up for SEPA direct-debit collection
When: Its mandate reference is created
Then: The reference is built from the donor's key plus the date, capped at 70 characters, and stored alongside the date the mandate was given; both are optional, since a gift needn't have a mandate
Source: Upgrade202206_202207.cs:66
Confidence: 0.89
Legacy Test Coverage: No legacy test coverage found for SEPA mandate reference generation specifically.
Behavioral Fidelity: The reference format and the 70-character limit are preserved. Internally the format gets its own validated type, and the schema change legacy made at runtime becomes a proper versioned migration.
9.7 Business Rule Traceability
The following matrix maps each rule to its Concho-verified source file and line range, evidence type, confidence score, GWT origin badge, and legacy-test corroboration:
- Code-inferred — the rule's Given-When-Then behavior was derived from the legacy source code alone. No surviving automated test exercises this rule, so the code is the single oracle for its intent.
- Code + test agree — a surviving legacy NUnit test also exercises this rule, and the behavior asserted by that test matches the behavior derived from the code (within the ≥0.85 agreement threshold). The two independent sources corroborate each other, which is why these rules carry higher effective confidence — the original team's intent is pinned down by an executable test, not just inferred from implementation.
| Rule ID | Rule Name | Source File | Lines | Confidence | Category | GWT Origin | Test Evidence |
|---|---|---|---|---|---|---|---|
| BR-GIFT-001 | Gift Batch Sequential Numbering | Gift.Batch.cs | 111 | 0.95 | Validation | Code+test agree | PostGiftBatch.test.cs |
| BR-GIFT-002 | Financial Period Validation | Gift.Batch.cs | 114 | 0.90 | Validation | Code+test agree | PostGiftBatch.test.cs |
| BR-GIFT-003 | Multi-Currency Gift Processing | GetDonationsOfDonor.sql; HOSAReportGiftSummary.sql | 7; 26 | 0.83 | Calculation | Code-inferred | — |
| BR-GIFT-004 | Donation Receipt Generation | GetDonationsOfDonor.sql | 37 | 0.87 | Workflow | Code+test agree | SingleGiftReceipt; AnnualReceipts |
| BR-GIFT-005 | Recurring Donation Scheduling | Upgrade202206_202207.cs; GenerateDonors.cs | 66; 97 | 0.70 | Workflow | Code+test agree | RecurringGiftBatch.test.cs |
| BR-GIFT-006 | Confidential Gift Privacy | GetDonationsOfDonor.sql | 27 | 0.70 | Workflow | Code-inferred | — |
| BR-GIFT-007 | Batch Posting Status Constraint | Gift.GetGiftsToAdjustField.sql | 6 | 0.90 | State Transition | Code+test agree | RevertAdjustGiftBatch.test.cs |
| BR-GIFT-008 | Unmodified Detail Filter | Gift.GetGiftsToAdjustField.sql | 11 | 0.89 | State Transition | Code-inferred | — |
| BR-GIFT-009 | Tax Deductible Percentage Bounds | TaxDeductibility.cs | 50 | 0.89 | Validation | Code-inferred | — |
| BR-GIFT-010 | Tax Deductibility Compliance Calc | Gift.TaxDeductiblePct.cs | 138–197 | 0.88 | Calculation | Code-inferred | — |
| BR-GIFT-011 | Motivation Detail by Partner Class | Gift.gui.tools.cs | 105–173 | 0.70 | Calculation | Code+test agree | SetMotivationGroupAndDetail.test.cs |
| BR-GIFT-012 | Posted Batch Report Integrity | methodofgiving.xml | 48 | 0.86 | Authorization | Code-inferred | — |
| BR-GIFT-013 | SEPA Mandate Reference Format | Upgrade202206_202207.cs | 66 | 0.89 | Authorization | Code-inferred | — |
| BR-GIFT-014 | Barcode Character Validation | BarCode128.cs | 51 | 0.80 | Validation | Code-inferred | — |
9.8 Field-Level Business Rules (auto-derived from field inventory)
Each row below is a Given/When/Then scenario derived from a single field's metadata on the storyboard JSON (required flag, validation regex, conditional collection, display masking, enum source). These complement the workflow-level BR-GIFT-NNN rules above by addressing the field level — the rules that drive BDD/TDD test generation — each scenario becomes a Playwright assertion against the running modern UI and the API contract. They are not authored by hand; they are generated by the field-level specification step of the modernization-planning workflow from the same storyboard JSON that drives Section 7.9's field inventory table. When the legacy source changes (required flag added, validation tightened, conditional flipped), regenerating the storyboard updates both the inventory table and these scenarios in lockstep.
Subsystem: Gift Processing — Annual Receipting Slice · Fields with derivable scenarios: 13 · Total scenarios: 16 · Scope coverage: rendering / validation / display / io
Test artifact: each row below corresponds to a Playwright test() block in petra-br.spec.ts (auto-generated by the test-generation step of the modernization-planning workflow). UI-testable rows (required, format, phone-mask) run live against the demo; rows that need backend support (conditional-hide, file upload, enum-validation) appear as test.skip() with the reason inline.
Step 1: Gift Batch Management
| Rule id | Scope | Given | When | Then |
|---|---|---|---|---|
BR-F-GIFT-GiftBatchManagement-a-batch-description-c-required-emptyfield: a_batch_description_c | validation | the Batch Description field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-GiftBatchManagement-a-gl-effective-date-d-required-emptyfield: a_gl_effective_date_d | validation | the Effective Date field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-GiftBatchManagement-a-date-entered-d-required-emptyfield: a_date_entered_d | validation | the Gift Date field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-GiftBatchManagement-p-donor-key-n-required-emptyfield: p_donor_key_n | validation | the Donor field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-GiftBatchManagement-a-gift-transaction-amount-n-required-emptyfield: a_gift_transaction_amount_n | validation | the Amount (Base Currency) field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-GiftBatchManagement-a-motivation-detail-code-c-required-emptyfield: a_motivation_detail_code_c | validation | the Motivation Detail field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-GiftBatchManagement-p-recipient-key-n-conditional-hidefield: p_recipient_key_n | rendering | the storyboard condition {'visibleWhen': 'motivation group = MEMBERFEE'} evaluates to false (tenant config / form state) | the finance officer renders Step 1 (Gift Batch Management) | the Membership Recipient field is not rendered in the DOMAnd: submitting the form succeeds without this field being present in the payload |
Step 2: Annual Receipt Configuration
| Rule id | Scope | Given | When | Then |
|---|---|---|---|---|
BR-F-GIFT-AnnualReceiptConfig-AStartDate-required-emptyfield: AStartDate | validation | the Start Date field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-AnnualReceiptConfig-AEndDate-required-emptyfield: AEndDate | validation | the End Date field is required | the finance officer submits the form with the field left empty | client-side validation blocks submit with a required-field error And: if the client check is bypassed, the API returns HTTP 422 with a field-level error |
BR-F-GIFT-AnnualReceiptConfig-AHTMLTemplate-upload-successfield: AHTMLTemplate | io | the finance officer selects a valid file for HTML Template | the file is uploaded | the upload succeeds and the field stores the file reference And: the UI confirms the upload (legacy field: AHTMLTemplate) |
BR-F-GIFT-AnnualReceiptConfig-AHTMLTemplate-upload-failurefield: AHTMLTemplate | io | the finance officer selects an invalid or corrupt file for HTML Template | the finance officer attempts the upload | no page navigation occurs And: an error message is displayed; the form state is unchanged until a valid file is provided |
BR-F-GIFT-AnnualReceiptConfig-ALogoImage-upload-successfield: ALogoImage | io | the finance officer selects a valid file for Logo Image | the file is uploaded | the upload succeeds and the field stores the file reference And: the UI confirms the upload (legacy field: ALogoImage) |
BR-F-GIFT-AnnualReceiptConfig-ALogoImage-upload-failurefield: ALogoImage | io | the finance officer selects an invalid or corrupt file for Logo Image | the finance officer attempts the upload | no page navigation occurs And: an error message is displayed; the form state is unchanged until a valid file is provided |
BR-F-GIFT-AnnualReceiptConfig-ASignatureImage-upload-successfield: ASignatureImage | io | the finance officer selects a valid file for Signature Image | the file is uploaded | the upload succeeds and the field stores the file reference And: the UI confirms the upload (legacy field: ASignatureImage) |
BR-F-GIFT-AnnualReceiptConfig-ASignatureImage-upload-failurefield: ASignatureImage | io | the finance officer selects an invalid or corrupt file for Signature Image | the finance officer attempts the upload | no page navigation occurs And: an error message is displayed; the form state is unchanged until a valid file is provided |
BR-F-GIFT-AnnualReceiptConfig-AEmailFrom-email-formatfield: AEmailFrom | validation | the Email From Address field expects a valid email address | the finance officer enters a value missing @, missing a TLD, or otherwise malformed | validation fails with an email-format error |
10. Data Mapping Strategy
receipt_issuance, receipt_template). Across all 8 entities we tracked ~94 columns; 0 fidelity findings against the legacy db/petra.xml master schema for the three entities shown in detail (a_gift_batch, a_gift_detail, a_motivation_detail). One declared deviation: COBOL-style "_l" boolean suffix and "_n" numeric suffix are dropped in modernized column names (e.g. a_modified_detail_l → modified_detail). Approach: typed-DataSet XML → PostgreSQL DDL → EF Core 10 entity, with denormalised audit columns added throughout.
Petra's legacy data layer is unusual for a system its age: it is already SQL-based (PostgreSQL, MySQL, or SQLite via the TDBType abstraction in the legacy code), and the table definitions are generated from a single master XML schema (db/petra.xml) into typed C# DataSets. The modernization replaces both ends of that chain: the master schema becomes EF Core migrations targeting PostgreSQL exclusively, and the typed DataSets become EF Core 10 entities. Because the data store is already SQL-on-Postgres in the production deployment, this section focuses on shape change (typed DataSet → entity / VARCHAR boolean suffixes → bool properties / column-name normalisation) rather than the radical structural transform that a COBOL VSAM modernization would require.
For the complete target data model ER diagram showing all entity relationships, see Section 4.4: Target Data Model.
10.1 Legacy Data Analysis
The subsystem owns 8 tables in the legacy schema (all rooted at db/petra.xml):
| Legacy Table | Lines in petra.xml | Purpose | Volume Estimate |
|---|---|---|---|
a_gift_batch | ~62 | Batch envelope: ledger, effective date, status, currency, journal reference, posting metadata | ~1,200 rows / year (typical mid-size non-profit ledger) |
a_gift | ~38 | Per-donor gift envelope inside a batch: donor key, date entered, receipt flags, method of giving | ~15,000 rows / year |
a_gift_detail | ~75 | Per-allocation line: recipient, motivation, three-currency amounts, tax-deductible split, modified-detail flag | ~25,000 rows / year |
a_motivation_group | ~18 | Reference: motivation group codes per ledger | ~80 rows total (slow-changing) |
a_motivation_detail | ~32 | Reference: motivation detail codes, GL account/cost-centre mappings, receipt-eligibility flag | ~450 rows total (slow-changing) |
a_recurring_gift_batch | ~58 | Mirror of a_gift_batch for recurring (SEPA) gifts | ~12 rows / year |
a_recurring_gift | ~46 | Mirror of a_gift plus SEPA mandate reference + date | ~600 active templates |
a_recurring_gift_detail | ~72 | Mirror of a_gift_detail for recurring allocations | ~900 active rows |
Three observations from the data analysis:
- COBOL-style column naming. Even though petra is a C# system, the SQL column names still carry COBOL-era suffixes —
_lfor boolean,_nfor numeric,_cfor character,_dfor date,_ifor integer. This is a vestige of the Pascal-language ancestor that petra modernized away from decades ago. The modernized schema drops these suffixes in favour of standard typed columns; the Section 4.4 ER diagram shows the target names. - Composite primary keys. Every table uses a composite PK that includes the ledger ID and runs through batch → gift → detail numbers. This composite-key strategy is preserved in the modernization — it makes natural-key idempotency in the CDC bridge (Section 11) trivial and avoids the impedance mismatch of inventing surrogate UUIDs.
- Three-currency amount columns. Every monetary value appears three times: transaction currency, base currency, international (ICH clearing) currency. This is structural to BR-GIFT-003 (Multi-Currency Gift Processing) and is preserved column-for-column in the modernized schema.
10.2 Schema Mappings
Three representative entities are mapped in detail below: gift_batch (the batch envelope), gift_detail (the multi-currency line item), and motivation_detail (the reference data joined into every gift). The remaining five tables follow the same patterns and are mapped in the code-and-test-generation workflow's EF Core migrations.
10.2.1 a_gift_batch → gift_batch
Legacy: petra.xml typed-DataSet definition
<!-- db/petra.xml (excerpt) --> <Table name="a_gift_batch"> <Field name="a_ledger_number_i" type="integer" not-null="yes"/> <Field name="a_batch_number_i" type="integer" not-null="yes"/> <Field name="a_batch_description_c" type="varchar" length="80"/> <Field name="a_batch_status_c" type="varchar" length="16" default="Unposted"/> <Field name="a_currency_code_c" type="varchar" length="10" not-null="yes"/> <Field name="a_exchange_rate_to_base_n" type="number" precision="24" scale="10" default="1.0"/> <Field name="a_gl_effective_date_d" type="date" not-null="yes"/> <Field name="a_batch_year_i" type="integer"/> <Field name="a_batch_period_i" type="integer"/> <Field name="a_batch_total_n" type="number" precision="13" scale="2" default="0"/> <Field name="a_hash_total_n" type="number" precision="13" scale="2" default="0"/> <Field name="a_gift_type_c" type="varchar" length="16" default="Gift"/> <Field name="a_method_of_payment_code_c" type="varchar" length="20"/> <PrimaryKey> <Column name="a_ledger_number_i"/> <Column name="a_batch_number_i"/> </PrimaryKey> </Table>
Database Schema: PostgreSQL DDL
-- migrations/001_create_gift_batch.sql
CREATE TABLE gift_processing.gift_batch (
ledger_id INTEGER NOT NULL,
batch_number INTEGER NOT NULL,
batch_description VARCHAR(80),
batch_status VARCHAR(16) NOT NULL
DEFAULT 'Unposted'
CHECK (batch_status IN
('Unposted','Posted','Cancelled')),
currency_code VARCHAR(10)
NOT NULL,
exchange_rate_to_base
NUMERIC(24, 10) NOT NULL DEFAULT 1.0,
gl_effective_date DATE NOT NULL,
batch_year INTEGER,
batch_period INTEGER,
batch_total NUMERIC(13, 2)
NOT NULL DEFAULT 0,
hash_total NUMERIC(13, 2)
NOT NULL DEFAULT 0,
gift_type VARCHAR(16)
NOT NULL DEFAULT 'Gift',
method_of_payment_code VARCHAR(20),
gl_journal_ref VARCHAR(40),
posted_at TIMESTAMPTZ,
posted_by VARCHAR(64),
created_at TIMESTAMPTZ
NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
NOT NULL DEFAULT now(),
created_by VARCHAR(64)
NOT NULL,
updated_by VARCHAR(64)
NOT NULL,
PRIMARY KEY (ledger_id, batch_number)
);
CREATE INDEX
ix_gift_batch_effective_date
ON gift_processing.gift_batch
(ledger_id, gl_effective_date);
CREATE INDEX
ix_gift_batch_status
ON gift_processing.gift_batch
(batch_status)
WHERE batch_status = 'Unposted';
Application Model: EF Core 10 Entity
// src/Petra.GiftProcessing/Domain/
// GiftBatch.cs
public enum BatchStatus
{ Unposted, Posted, Cancelled }
public class GiftBatch
{
public int LedgerId { get; set; }
public int BatchNumber
{ get; private set; }
public string? BatchDescription
{ get; set; }
public BatchStatus BatchStatus
{ get; private set; }
= BatchStatus.Unposted;
public string CurrencyCode { get; set; }
= "EUR";
public decimal ExchangeRateToBase
{ get; set; } = 1.0m;
public DateOnly GlEffectiveDate
{ get; set; }
public int? BatchYear { get; set; }
public int? BatchPeriod { get; set; }
public decimal BatchTotal { get; set; }
public decimal HashTotal { get; set; }
public string GiftType { get; set; }
= "Gift";
public string? MethodOfPaymentCode
{ get; set; }
public string? GlJournalRef
{ get; private set; }
public DateTimeOffset? PostedAt
{ get; private set; }
public string? PostedBy
{ get; private set; }
// Audit
public DateTimeOffset CreatedAt
{ get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt
{ get; set; } = DateTimeOffset.UtcNow;
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
// Children
public List<Gift> Gifts { get; }
= new();
// Behaviour (BR-GIFT-007)
public void MarkPosted(
string user,
string journalRef)
{
if (BatchStatus != BatchStatus.Unposted)
throw new BatchAlreadyPostedException(
LedgerId, BatchNumber);
BatchStatus = BatchStatus.Posted;
PostedAt = DateTimeOffset.UtcNow;
PostedBy = user;
GlJournalRef = journalRef;
}
}
Schema Mapping Notes — a_gift_batch → gift_batch
| Legacy Field | petra.xml Type | Target Column | Target Type | Transformation |
|---|---|---|---|---|
a_ledger_number_i | integer | ledger_id | INTEGER NOT NULL | Drop a_ prefix and _i suffix; rename to standard PK shape |
a_batch_number_i | integer | batch_number | INTEGER NOT NULL | Drop a_/_i suffixes; preserved as part of composite PK |
a_batch_description_c | varchar(80) | batch_description | VARCHAR(80) | Width preserved (BR-GIFT compatibility) |
a_batch_status_c | varchar(16) default 'Unposted' | batch_status | VARCHAR(16) + CHECK | CHECK constraint added; EF Core value converter maps to BatchStatus enum (BR-GIFT-007) |
a_currency_code_c | varchar(10) | currency_code | VARCHAR(10) NOT NULL | Default tightened to "EUR" in entity for European-non-profit context |
a_exchange_rate_to_base_n | number(24,10) default 1.0 | exchange_rate_to_base | NUMERIC(24,10) NOT NULL | Precision preserved exactly (BR-GIFT-003) |
a_gl_effective_date_d | date | gl_effective_date | DATE NOT NULL | EF Core maps to DateOnly |
a_batch_total_n | number(13,2) | batch_total | NUMERIC(13,2) | Precision preserved; decimal.Round(.., 2) in domain method |
| — (new) | — | gl_journal_ref | VARCHAR(40) | NEW: GL journal reference returned from strangler-bridge call |
| — (new) | — | posted_at / posted_by | TIMESTAMPTZ / VARCHAR(64) | NEW: operational audit on the posting transition (BR-GIFT-007) |
| — (new) | — | created_at / updated_at / created_by / updated_by | TIMESTAMPTZ / VARCHAR(64) | NEW: universal audit columns (Schema Migration Design Rules §10.8 / Audit Trail) |
Indexing: the partial index on batch_status = 'Unposted' mirrors the legacy hot-path query "show me batches I still need to post." On a typical mid-size non-profit, this is at most 5–10 rows out of ~1,200 / year, so the partial index is one or two pages.
Volume: ~1,200 rows/year × ~7 years of retention ≈ ~8,500 rows total. Backfill from legacy is a single COPY ... FROM statement; CDC keeps the modern table in sync per Section 11.
10.2.2 a_gift_detail → gift_detail (the multi-currency line item)
Legacy: petra.xml typed-DataSet definition
<Table name="a_gift_detail">
<Field name="a_ledger_number_i"
type="integer" not-null="yes"/>
<Field name="a_batch_number_i"
type="integer" not-null="yes"/>
<Field name="a_gift_transaction_number_i"
type="integer" not-null="yes"/>
<Field name="a_detail_number_i"
type="integer" not-null="yes"/>
<Field name="a_motivation_group_code_c"
type="varchar" length="24"
not-null="yes"/>
<Field name="a_motivation_detail_code_c"
type="varchar" length="24"
not-null="yes"/>
<Field name="p_recipient_key_n"
type="bigint" default="0"/>
<Field name="a_recipient_ledger_number_n"
type="bigint" default="0"/>
<Field name="a_gift_transaction_amount_n"
type="number" precision="13"
scale="2" not-null="yes"/>
<Field name="a_gift_amount_n"
type="number" precision="13"
scale="2" not-null="yes"/>
<Field name="a_gift_amount_intl_n"
type="number" precision="13"
scale="2" not-null="yes"/>
<Field name="a_tax_deductible_pct_n"
type="number" precision="5"
scale="2" default="0"/>
<Field name="a_tax_deductible_amount_n"
type="number" precision="13"
scale="2" default="0"/>
<Field name="a_non_deductible_amount_n"
type="number" precision="13"
scale="2" default="0"/>
<Field
name="a_tax_deductible_amount_base_n"
type="number" precision="13"
scale="2" default="0"/>
<Field
name="a_non_deductible_amount_base_n"
type="number" precision="13"
scale="2" default="0"/>
<Field name="a_modified_detail_l"
type="bit" default="0"/>
<Field name="a_confidential_gift_flag_l"
type="bit" default="0"/>
<PrimaryKey>
<Column name="a_ledger_number_i"/>
<Column name="a_batch_number_i"/>
<Column
name="a_gift_transaction_number_i"/>
<Column name="a_detail_number_i"/>
</PrimaryKey>
</Table>
Database Schema: PostgreSQL DDL
CREATE TABLE gift_processing.gift_detail (
ledger_id INTEGER NOT NULL,
batch_number INTEGER NOT NULL,
gift_transaction_number INTEGER
NOT NULL,
detail_number INTEGER NOT NULL,
motivation_group_code VARCHAR(24)
NOT NULL,
motivation_detail_code VARCHAR(24)
NOT NULL,
recipient_partner_key BIGINT
NOT NULL DEFAULT 0,
recipient_ledger_number BIGINT
NOT NULL DEFAULT 0,
gift_transaction_amount
NUMERIC(13, 2) NOT NULL,
gift_amount NUMERIC(13, 2)
NOT NULL,
gift_amount_intl NUMERIC(13, 2)
NOT NULL,
tax_deductible_pct NUMERIC(5, 2)
NOT NULL DEFAULT 0
CHECK (tax_deductible_pct
BETWEEN 0 AND 100),
tax_deductible_amount NUMERIC(13, 2)
NOT NULL DEFAULT 0,
non_deductible_amount NUMERIC(13, 2)
NOT NULL DEFAULT 0,
tax_deductible_amount_base
NUMERIC(13, 2) NOT NULL DEFAULT 0,
non_deductible_amount_base
NUMERIC(13, 2) NOT NULL DEFAULT 0,
tax_deductible_amount_intl
NUMERIC(13, 2) NOT NULL DEFAULT 0,
non_deductible_amount_intl
NUMERIC(13, 2) NOT NULL DEFAULT 0,
modified_detail BOOLEAN
NOT NULL DEFAULT FALSE,
confidential_gift_flag BOOLEAN
NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ
NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
NOT NULL DEFAULT now(),
created_by VARCHAR(64)
NOT NULL,
updated_by VARCHAR(64)
NOT NULL,
PRIMARY KEY
(ledger_id, batch_number,
gift_transaction_number,
detail_number),
FOREIGN KEY
(ledger_id, batch_number,
gift_transaction_number)
REFERENCES
gift_processing.gift
(ledger_id, batch_number,
gift_transaction_number)
);
CREATE INDEX
ix_gift_detail_recipient
ON gift_processing.gift_detail
(recipient_partner_key);
CREATE INDEX
ix_gift_detail_motivation
ON gift_processing.gift_detail
(motivation_group_code,
motivation_detail_code);
Application Model: EF Core 10 Entity
// src/Petra.GiftProcessing/Domain/
// GiftDetail.cs
public class GiftDetail
{
// Composite PK (parts mirror DDL)
public int LedgerId { get; set; }
public int BatchNumber { get; set; }
public int GiftTransactionNumber
{ get; set; }
public int DetailNumber { get; set; }
public string MotivationGroupCode
{ get; set; } = "GIFT";
public string MotivationDetailCode
{ get; set; } = "SUPPORT";
public long RecipientPartnerKey
{ get; set; }
public long RecipientLedgerNumber
{ get; set; }
// BR-GIFT-003 three-currency
public decimal GiftTransactionAmount
{ get; set; }
public decimal GiftAmount { get; set; }
public decimal GiftAmountIntl
{ get; set; }
// BR-GIFT-009 + BR-GIFT-010 outputs
public decimal TaxDeductiblePct
{ get; private set; }
public decimal TaxDeductibleAmount
{ get; private set; }
public decimal NonDeductibleAmount
{ get; private set; }
public decimal TaxDeductibleAmountBase
{ get; private set; }
public decimal NonDeductibleAmountBase
{ get; private set; }
public decimal TaxDeductibleAmountIntl
{ get; private set; }
public decimal NonDeductibleAmountIntl
{ get; private set; }
// BR-GIFT-008
public bool ModifiedDetail
{ get; private set; }
// BR-GIFT-006
public bool ConfidentialGiftFlag
{ get; set; }
public DateTimeOffset CreatedAt
{ get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt
{ get; set; } = DateTimeOffset.UtcNow;
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
// Navigation
public Gift Gift { get; set; } = null!;
public MotivationDetail MotivationDetail
{ get; set; } = null!;
public Partner Recipient { get; set; }
= null!;
public Partner GiftDestination
{ get; set; } = null!;
}
Schema Mapping Notes — a_gift_detail → gift_detail
| Legacy Field | petra.xml Type | Target Column | Target Type | Transformation / BR |
|---|---|---|---|---|
a_modified_detail_l | bit default 0 | modified_detail | BOOLEAN NOT NULL DEFAULT FALSE | Suffix dropped; bit→bool. BR-GIFT-008 sets it on adjust |
a_confidential_gift_flag_l | bit default 0 | confidential_gift_flag | BOOLEAN NOT NULL DEFAULT FALSE | Drives BR-GIFT-006 projection-time authorization filter |
a_tax_deductible_pct_n | number(5,2) | tax_deductible_pct | NUMERIC(5,2) + CHECK 0–100 | BR-GIFT-009 bounds enforced at DB level too (belt-and-braces with domain method) |
a_gift_transaction_amount_n / a_gift_amount_n / a_gift_amount_intl_n | 3× number(13,2) | gift_transaction_amount / gift_amount / gift_amount_intl | 3× NUMERIC(13,2) | Three-currency model preserved column-for-column (BR-GIFT-003) |
p_recipient_key_n | bigint default 0 | recipient_partner_key | BIGINT NOT NULL DEFAULT 0 | FK constraint dropped per sliceBoundaryFKPolicy: drop-constraints; partner validation happens at the application layer via the legacy-Partner strangler bridge |
a_recipient_ledger_number_n | bigint default 0 | recipient_ledger_number | BIGINT NOT NULL DEFAULT 0 | Same drop-constraints policy; used as the "GiftDestination" alias path for BR-GIFT-006 |
Indexing rationale: the recipient index supports the receipt-generation query (see Section 8.7); the motivation index supports the field-adjustment workflow (BR-GIFT-010 → GetGiftsForTaxDeductiblePctAdjustment).
Volume: ~25,000 rows/year. Year-end annual-receipt run reads ~5–15% of total rows (donors active in the past tax year). With the recipient index in place, that's a ~3,000-row index scan per donor pass.
10.2.3 a_motivation_detail → motivation_detail (reference data)
Legacy: petra.xml typed-DataSet definition
<Table name="a_motivation_detail">
<Field name="a_ledger_number_i"
type="integer" not-null="yes"/>
<Field name="a_motivation_group_code_c"
type="varchar" length="24"
not-null="yes"/>
<Field name="a_motivation_detail_code_c"
type="varchar" length="24"
not-null="yes"/>
<Field name="a_motivation_detail_desc_c"
type="varchar" length="80"/>
<Field name="a_account_code_c"
type="varchar" length="16"/>
<Field name="a_cost_centre_code_c"
type="varchar" length="20"/>
<Field name="a_motivation_status_l"
type="bit" default="1"/>
<Field name="a_receipt_l"
type="bit" default="1"/>
<Field name="a_tax_deductible_l"
type="bit" default="1"/>
<Field name="p_linked_partner_key_n"
type="bigint" default="0"/>
<PrimaryKey>
<Column name="a_ledger_number_i"/>
<Column
name="a_motivation_group_code_c"/>
<Column
name="a_motivation_detail_code_c"/>
</PrimaryKey>
</Table>
Database Schema: PostgreSQL DDL
CREATE TABLE
gift_processing.motivation_detail
(
ledger_id INTEGER NOT NULL,
motivation_group_code VARCHAR(24)
NOT NULL,
motivation_detail_code VARCHAR(24)
NOT NULL,
motivation_detail_desc VARCHAR(80),
account_code VARCHAR(16),
cost_centre_code VARCHAR(20),
motivation_status BOOLEAN
NOT NULL DEFAULT TRUE,
receipt_eligible BOOLEAN
NOT NULL DEFAULT TRUE,
tax_deductible BOOLEAN
NOT NULL DEFAULT TRUE,
linked_partner_key BIGINT
NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ
NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ
NOT NULL DEFAULT now(),
created_by VARCHAR(64)
NOT NULL,
updated_by VARCHAR(64)
NOT NULL,
PRIMARY KEY
(ledger_id, motivation_group_code,
motivation_detail_code),
FOREIGN KEY
(ledger_id, motivation_group_code)
REFERENCES
gift_processing.motivation_group
(ledger_id, motivation_group_code)
);
Application Model: EF Core 10 Entity
// src/Petra.GiftProcessing/Domain/
// MotivationDetail.cs
public class MotivationDetail
{
// Composite PK
public int LedgerId { get; set; }
public string MotivationGroupCode
{ get; set; } = "";
public string MotivationDetailCode
{ get; set; } = "";
public string? MotivationDetailDesc
{ get; set; }
public string? AccountCode { get; set; }
public string? CostCentreCode
{ get; set; }
public bool MotivationStatus
{ get; set; } = true;
// BR-GIFT-004 (receipt eligibility flag)
public bool ReceiptEligible
{ get; set; } = true;
public bool TaxDeductible
{ get; set; } = true;
// BR-GIFT-011 (linked partner lookup)
public long LinkedPartnerKey
{ get; set; }
public DateTimeOffset CreatedAt
{ get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAt
{ get; set; } = DateTimeOffset.UtcNow;
public string CreatedBy { get; set; } = "";
public string UpdatedBy { get; set; } = "";
// Navigation
public MotivationGroup Group
{ get; set; } = null!;
public List<GiftDetail> GiftDetails
{ get; } = new();
}
// Domain method for receipt eligibility
public static class MotivationSpecs
{
// BR-GIFT-004 second flag
public static IQueryable<GiftDetail>
OnReceiptEligibleMotivation(
this IQueryable<GiftDetail> q)
=> q.Where(d =>
d.MotivationDetail.ReceiptEligible);
}
Schema Mapping Notes — a_motivation_detail → motivation_detail
| Legacy Field | petra.xml Type | Target Column | Target Type | Transformation / BR |
|---|---|---|---|---|
a_motivation_status_l | bit default 1 | motivation_status | BOOLEAN NOT NULL DEFAULT TRUE | Active-flag preserved (soft-delete model) |
a_receipt_l | bit default 1 | receipt_eligible | BOOLEAN NOT NULL DEFAULT TRUE | Renamed for clarity; drives BR-GIFT-004 second flag in the receipt-eligibility dual-flag predicate |
a_tax_deductible_l | bit default 1 | tax_deductible | BOOLEAN NOT NULL DEFAULT TRUE | Drives BR-GIFT-010 (only tax-deductible motivations get pct adjustments) |
p_linked_partner_key_n | bigint default 0 | linked_partner_key | BIGINT NOT NULL DEFAULT 0 | Drives BR-GIFT-011 UNIT-partner motivation lookup |
a_account_code_c / a_cost_centre_code_c | varchar(16) / varchar(20) | account_code / cost_centre_code | VARCHAR(16) / VARCHAR(20) | GL routing keys for strangler-bridge posting payload |
Volume: ~450 rows total, slow-changing. Eligible for full table-cache in EF Core's second-level cache (Microsoft.EntityFrameworkCore.Caching) at startup — eliminates the per-gift-detail JOIN cost on the receipt-generation hot path.
10.3 Data Type Mappings
Because petra is already a SQL-on-PostgreSQL system, the petra.xml → PostgreSQL DDL type map is small and mostly identity. The handful of consequential rules:
| petra.xml Type | Example | PostgreSQL Type | EF Core / C# Type | Notes |
|---|---|---|---|---|
integer | a_batch_number_i | INTEGER | int | Identity transform; suffix _i dropped |
bigint | p_recipient_key_n | BIGINT | long | Partner keys are 10-digit numeric — bigint required |
number(p, s) | a_gift_amount_n (13,2) | NUMERIC(p, s) | decimal | Precision/scale preserved exactly; never float/double for monetary fields (Universal Design Rules §10.8) |
number(24, 10) | a_exchange_rate_to_base_n | NUMERIC(24, 10) | decimal | FX exchange rate needs the extra 10 decimal places for BR-GIFT-003 inverse-rate math |
varchar(n) | a_batch_description_c | VARCHAR(n) | string | Width preserved; trailing-space trim handled in EF Core value converter; suffix _c dropped |
date | a_gl_effective_date_d | DATE | DateOnly | EF Core 10 supports DateOnly natively in Npgsql; legacy used DateTime with the time component truncated |
bit | a_modified_detail_l | BOOLEAN | bool | Suffix _l dropped; legacy used 0/1 in INTEGER columns when targeting MySQL/SQLite, BOOLEAN in PostgreSQL — modernization standardises on BOOLEAN |
| (new) | created_at, updated_at | TIMESTAMPTZ | DateTimeOffset | Universal audit columns added to every entity |
10.4 Data Transfer Procedures
Petra is already SQL-on-Postgres in production, so the data-transfer story is two parts: an initial backfill from the legacy database into the modernized schema, and the ongoing CDC stream that keeps the modern schema in sync until the legacy database is decommissioned. The CDC stream is the strangler-fig bridge described in detail in Section 11; this subsection covers the backfill side.
10.4.1 Backfill Pipeline (One-Time Per Ledger)
The backfill is run once per ledger and once per pre-cutover validation rehearsal. It is a pure SQL pipeline — no application code involved — because the legacy and modernized schemas live on the same PostgreSQL engine family.
- Snapshot.
pg_dump --schema-only --table='a_gift_*' --table='a_motivation_*'against the legacy DB to capture the schema; then aSELECTwithFOR SHAREon the ledger's rows to lock the source while the snapshot is taken. - Transform. A single SQL statement per target table renames columns, casts types, and adds audit columns:
-- backfill: gift_batch INSERT INTO gift_processing.gift_batch ( ledger_id, batch_number, batch_description, batch_status, currency_code, exchange_rate_to_base, gl_effective_date, batch_year, batch_period, batch_total, hash_total, gift_type, method_of_payment_code, gl_journal_ref, posted_at, posted_by, created_at, updated_at, created_by, updated_by ) SELECT a_ledger_number_i, a_batch_number_i, NULLIF(a_batch_description_c, ''), a_batch_status_c, a_currency_code_c, a_exchange_rate_to_base_n, a_gl_effective_date_d, a_batch_year_i, a_batch_period_i, COALESCE(a_batch_total_n, 0), COALESCE(a_hash_total_n, 0), COALESCE(a_gift_type_c, 'Gift'), NULLIF(a_method_of_payment_code_c, ''), NULL, -- gl_journal_ref backfilled -- from legacy GL via bridge CASE WHEN a_batch_status_c = 'Posted' THEN a_date_modified_d::TIMESTAMPTZ ELSE NULL END, CASE WHEN a_batch_status_c = 'Posted' THEN a_modified_by_c ELSE NULL END, COALESCE(a_date_created_d::TIMESTAMPTZ, now()), COALESCE(a_date_modified_d::TIMESTAMPTZ, now()), COALESCE(a_created_by_c, 'backfill'), COALESCE(a_modified_by_c, 'backfill') FROM legacy.a_gift_batch WHERE a_ledger_number_i = $1 ON CONFLICT (ledger_id, batch_number) DO NOTHING; - Load.
INSERT … ON CONFLICT DO NOTHINGon every table; idempotent so the backfill is re-runnable. - Verify. See §10.5 below.
10.4.2 Schema Transformation Pipeline
Once backfill completes, the schema-transformation pipeline takes over for incremental sync:
| Stage | Mechanism | Cadence | Reference |
|---|---|---|---|
| 1. Capture | Debezium PostgreSQL connector (pgoutput plugin) reading the legacy DB's WAL via dedicated replication slot petra_gift_slot | Continuous, ~50–200 ms lag steady-state | §11 Cutover Choreography |
| 2. Translate | In-process translator inside Gift Processing API converts each captured row to the modern column shape using the same transform SQL as backfill | Per-message | §11 Translator |
| 3. Route | Azure Service Bus session-ordered topic keyed by (ledger_id, batch_id); CloudEvents 1.0 envelope per the sage-domain-event-v1 schema | Per-message | §11 Messaging |
| 4. Apply | Background-thread consumer in Gift Processing API performs natural-key upsert into the modern tables | Continuous | §11 Target |
| 5. Reconcile | Hourly job compares row-count + checksum of (ledger_id, batch_id) tuples between legacy and modern; mismatches surface to ops dashboard | Hourly | §10.5 |
10.5 Data Validation Strategy
Four independent validation paths run during backfill rehearsals and after every cutover stage; all four must pass for the stage to be considered green.
| Path | Mechanism | Pass Criterion |
|---|---|---|
| Row counts | SELECT COUNT(*) FROM <legacy> vs SELECT COUNT(*) FROM <modern> per table per ledger | Exact match |
| Checksums | SELECT md5(string_agg(...)) over a stable column ordering, scoped to one ledger at a time | Exact match |
| BR-rule sampling | Random sample of 1,000 gift_detail rows; recompute three-currency totals and tax-deductible split using the modernized domain code, compare against stored values | Zero discrepancies, ≤ 0.01 EUR rounding tolerance |
| Posted-batch invariant | For every batch_status = 'Posted' batch, verify SUM(gift_detail.gift_amount) = gift_batch.batch_total | Exact equality (£0 / €0 / $0) |
The BR-rule sampling pass is the only one that exercises the modernized domain code path — if FluentValidation, the EF Core entity, or the BR-GIFT-010 calculation diverges from legacy by even 0.01 EUR per detail, this is where it surfaces. Tolerance is set tight (≤ 0.01) because monetary fidelity is the load-bearing property of the subsystem.
10.6 Rollback Procedures
Three rollback affordances stack from cheapest to most expensive:
- APIM traffic flip. Reverse the per-endpoint traffic shift in Section 11: route 100% of traffic back to the legacy ASMX endpoint. Modern PostgreSQL is left intact (read-only); CDC stream continues running but no new writes hit the modern API. Reversible in < 1 minute.
- Per-ledger replay. Truncate the modern tables for a single ledger; re-run the backfill INSERT statements. The composite PK
(ledger_id, batch_number, ...)scopes the truncate cleanly; other ledgers stay live. Reversible in 10–30 minutes per ledger. - Point-in-time recovery. Azure Database for PostgreSQL Flexible Server is configured with 7-day backup retention; a full PITR restore takes 15–90 minutes depending on database size. Used only if the modern DB has been corrupted in a way that the targeted truncate cannot fix.
10.7 Mapping Summary
| Entity | Records (est., 7-yr) | Complexity | Approach | Backfill Duration (est.) |
|---|---|---|---|---|
| gift_batch | ~8,500 | Low (envelope only) | Direct INSERT … SELECT | < 30s |
| gift | ~105,000 | Low | Direct INSERT … SELECT | ~1 min |
| gift_detail | ~175,000 | Medium (three-currency split) | Direct INSERT … SELECT + BR-rule sampling validation | ~3 min |
| motivation_group + motivation_detail | ~530 | Low | Direct INSERT … SELECT + cache warm-up | < 5s |
| recurring_gift_batch + recurring_gift + recurring_gift_detail | ~10,000 | Medium (SEPA mandates) | Direct INSERT … SELECT + SEPA-format validation | ~1 min |
10.8 Schema Migration Design Rules
These rules govern every design decision when translating legacy data structures to the modernized data model. They are split into universal rules (apply regardless of target database engine) and engine-specific rules (implementation details for PostgreSQL, DynamoDB, MongoDB). Together they ensure consistency between the schema artifact (DDL), the ORM/document model, and the seed data.
10.8.1 Universal Rules (Engine-Agnostic)
| Decision | Rule | Rationale | Example |
|---|---|---|---|
| Field Naming | Drop legacy table-prefix (a_, p_) and type-suffix (_l, _n, _c, _d, _i). Expand opaque abbreviations to readable snake_case. |
Petra's COBOL-era suffixes don't survive contact with modern tooling; explicit names read better in code review and SQL traces. | a_modified_detail_l → modified_detail; a_gift_amount_intl_n → gift_amount_intl |
| Numeric Precision | Preserve NUMERIC(p, s) precision/scale exactly from petra.xml. Never use float/double for monetary or percentage fields. |
BR-GIFT-003, BR-GIFT-009, BR-GIFT-010 all encode monetary semantics; a sub-cent rounding error compounds to a regulatory issue. | number(13, 2) → NUMERIC(13, 2) exactly; FX rate stays NUMERIC(24, 10) |
| OCCURS Elimination | Always child entities with parent references, never fixed-size arrays. Petra's typed DataSet didn't carry OCCURS, but the same rule applies to the recurring-gift ↔ recurring-gift-detail relationship. | Unlimited recurring detail rows; child-FK enables clean idempotent upserts in the CDC stream. | recurring_gift 1:N recurring_gift_detail via composite FK |
| Audit Trail | Every entity gets created_at, updated_at, created_by, updated_by columns. |
Application Insights queries pivot on these; legacy s_date_modified_d / s_modified_by_c are migrated forward as the new updated_* values. |
All 8 gift tables + the 2 new receipt tables |
| New Operational Entities | Mark explicitly "NEW — no legacy equivalent" in mapping notes. | Avoid confusion about what is migrated vs newly introduced. | receipt_issuance, receipt_template (Receipt Generation Service) |
| Reference Data | Read-only at the gift-processing service boundary; mastered by upstream subsystems (Partner, GL). FK constraints dropped per sliceBoundaryFKPolicy: drop-constraints. |
Strangler-fig bridge to legacy Partner/GL services remains the system of record until those subsystems modernize. | recipient_partner_key, ledger_id, currency codes |
| String Field Widths | Map to legacy width exactly; widen explicitly only when an affinity-win demands it. | Backfill INSERT will fail loudly on width overflow instead of silently truncating — cheaper to detect. | batch_description VARCHAR(80); SEPA mandate ref stays VARCHAR(70) |
10.8.2 Engine-Specific Rules
| Decision | PostgreSQL/Aurora (this project) | DynamoDB | MongoDB/DocumentDB |
|---|---|---|---|
| Primary Keys | Composite (preserved from legacy): (ledger_id, batch_number, ...). Surrogate id BIGSERIAL added only for the two new receipt-service tables. |
PK + SK design: PK = LEDGER#<id>, SK = BATCH#<number>#DETAIL#<n> |
ObjectId or compound _id: { ledger, batch, ... } |
| Numeric Precision | NUMERIC(p, s) exactly |
N type with application-level precision documentation |
Decimal128 |
| Relationships | FK constraints for in-service relationships (gift_batch ↔ gift ↔ gift_detail); dropped at service boundary | Denormalize / GSIs | Embed for high read-locality, reference otherwise |
| Computed Values | GENERATED ALWAYS STORED for immutable expressions only (none in this project — tax-split is application-computed); CURRENT_DATE / volatile expressions go in views or domain properties |
Compute-on-write | Aggregation pipeline |
| Audit Trail | Columns + BEFORE UPDATE trigger or EF Core SaveChangesInterceptor (this project uses the interceptor) |
Attributes + DynamoDB Streams | Fields + Change Streams |
| Schema Artifact | schema.sql (EF Core migrations → generated SQL script) |
table-definitions.json + per-item schemas |
validation-schemas.json (JSON Schema) |
| OCCURS → Children | Child table + composite FK (recurring_gift → recurring_gift_detail) | Child items: same PK, different SK | Embedded array OR separate collection (size-bounded decision) |
| String Widths | VARCHAR(N) mapped to legacy width; CHECK constraint for short-enum strings (e.g. batch_status) |
S type with application-level width |
String with maxLength in JSON Schema |
schema.sql emitted from EF Core migrations). The core-logic agent then reads that artifact and generates ORM/document models that match it exactly. The orchestrator does not need to branch — the same phase order works for all engines (PostgreSQL, DynamoDB, MongoDB). For petra the database engine is fixed to PostgreSQL by report-plan.json, but the rule keeps the workflow portable.
For the overall modernization execution plan including phased cutover, dual-write implementation, and rollback procedures, see Section 11: Modernization Strategy.
11. Modernization Strategy
serverMFinance.asmx endpoints run side-by-side; APIM shifts traffic per endpoint group, reads first then writes. A Debezium-driven Azure Service Bus bridge keeps the modern PostgreSQL in sync during the overlap. Non-idempotent writes (atomic batch numbering, financial-period validation, reversal de-duplication) rule out symmetric dual-write; the cutover is a business decision, not a technical deadline.
This section defines the modernization execution strategy and the legacy/modern coexistence plan for the Finance — Gift Processing subsystem. The goal is to keep donation processing — the core revenue workflow for non-profit organizations — running uninterrupted on the existing Petra (OpenPetra) platform while the new Angular 20 + .NET 10 stack is incrementally validated and accepts production traffic.
11.1 Why the Classical Strangler-Fig Pattern Fits This System
The coexistence pattern is selected by applying a decision tree to the legacy system's interaction surface — the shape of the interface through which clients reach the system's business logic. The tree has one pivotal question:
Does the source system have a request-routing surface (load balancer, API gateway, reverse proxy)?
For Petra, the answer is unambiguously YES.
Concho analysis of Petra's Finance — Gift Processing entry points confirms a conventional HTTP-routable interaction surface. The legacy subsystem is reached through ASP.NET Web Services (.asmx) served by Mono / FastCGI behind an nginx reverse proxy, with four primary entry points:
- TGiftTransactionWebConnector (confidence 0.90) — RPC endpoint exposing gift batch creation, posting, validation, transaction management, annual receipt generation, and financial-period operations.
- AnnualReceiptGenerationInterface (confidence 0.70) — HTTP form interface for template upload, email configuration, and bulk annual tax-receipt generation.
- GiftBatchManagementInterface (confidence 0.65) — HTTP interface for managing gift batches and transactions, with bookmarkable URL navigation.
- BankImportManagementInterface (confidence 0.60) — HTTP interface for uploading bank statements (CAMT v053, MT940, CSV, ZIP).
These four entry points all sit behind the same nginx routing layer. That alone rules out SAROC, which is reserved for systems whose only interaction surface is a green-screen terminal, a batch-file drop, or a thick desktop client writing directly to a shared database. With a request-routing surface present, the decision tree advances to its second branch:
Are writes idempotent and is divergence acceptable for short windows?
For Petra Gift Processing, the answer is unambiguously NO. Three Concho-identified business rules establish non-idempotent, ordering-sensitive write semantics that make symmetric dual-write unsafe:
- GiftBatchSequentialNumbering (confidence 0.95) — Gift batch numbers are assigned by atomically incrementing
LastGiftBatchNumberon the ledger table. The counter is non-idempotent; if both systems accept the same logical batch-creation in parallel, the two writes claim different batch numbers and the resultinggift → gift_detail → motivationhierarchy diverges between systems. - GiftBatchFinancialPeriodValidation (confidence 0.90) — Gift batch effective dates must fall within an open accounting period, validated by
TFinancialYearwith a force-fit option. A batch posted in legacy that references a period later closed in the modern system (or vice versa) violates this constraint and silently corrupts the GL. - MultiCurrencyGiftProcessingRule (confidence 0.83) — Multi-currency processing maintains separate tracking of transaction amounts, base amounts, and international amounts derived from exchange-rate snapshots. If the two systems pick the same gift up with different exchange-rate snapshots, the tax-deductible total and the GL credit diverge in opposite directions.
The middle branch of the decision tree therefore selects classical strangler-fig: an API gateway progressively shifts traffic from legacy endpoints to modern endpoints, reads first then writes, with a single source of truth at every moment for every endpoint. Symmetric dual-write — where both systems accept writes during the overlap — is explicitly off the table.
Decision: Classical Strangler-Fig
Reason: Petra exposes Gift Processing through HTTP-routable SOAP/RPC endpoints behind an nginx reverse proxy — a request-routing surface exists, so SAROC is not selected. Writes are non-idempotent in three Concho-identified ways (atomic batch numbering, financial-period validation, multi-currency ordering), so symmetric dual-write is also ruled out. Azure API Management sits in front of both stacks and shifts traffic per endpoint group, reads first then writes, with rollback by reverting the routing policy.
Implementation status: pending. In the v1 Concho code-and-test-generation workflow only SAROC has fully wired bridge code generation; the classical strangler-fig narrative, choreography, and operational guidance in this section are complete and implementable, but the bridge artifacts agent will emit a stub plus a clear “strangler-fig artifact generation not yet implemented” notice rather than guess at APIM policy code.
11.2 EIP Notation Diagram of the Bridge
Although the classical strangler-fig pattern operates primarily at the routing layer — Azure API Management shifts traffic per endpoint group — the data plane still requires a synchronization bridge during the overlap window. As long as some endpoints route to legacy, the writes those endpoints accept must reach the modern PostgreSQL before any modernized endpoint reads stale data. That synchronization bridge follows the canonical four-component Enterprise Integration Pattern (EIP) shape from Hohpe & Woolf (2003): Channel Adapter → Message Translator → Message Channel → Message Endpoint.
Figure 11.1 — Reliable Message Queue Pattern (EIP component view). During the strangler-fig coexistence window, mutations applied through legacy serverMFinance.asmx endpoints are captured by a Debezium connector on the legacy PostgreSQL WAL, translated into JSON domain events, and forwarded through Azure Service Bus (session-ordered per gift batch) to a background-thread consumer in the modern Gift Processing API.
Note on Figure 11.2 / 11.3. The SAROC topology diagram (two-paths bulk-ETL + queue-replay) is specific to the SAROC pattern and does not apply to classical strangler-fig — strangler-fig is driven by per-endpoint traffic shifting, not a one-shot bulk ETL. The temporal sequence for strangler-fig is shown in Section 11.3 as a Mermaid sequence diagram (Figure 11.2 below). Per-pattern Figure 11.2 / 11.3 templates for classical strangler-fig will land in the legacy-coexistence skill when the code-and-test-generation workflow's bridge generation for this pattern reaches implementationStatus: full.
11.3 Cutover Choreography
The classical strangler-fig cutover is driven by Azure API Management (APIM) traffic-shifting policies, not by a queue-replay event. APIM owns the public surface; behind it sit the legacy Mono / serverMFinance.asmx endpoints and the modern .NET 10 services. Each APIM policy decides, per request path and per HTTP method, which backend handles the call. The cutover advances through six phases.
Phase 1 — Deploy modern services in shadow mode
Gift Processing API and Receipt Generation Service are deployed to Azure App Service (P1v3 Linux) alongside the legacy Mono/FastCGI infrastructure. APIM routes 100% of gift-processing traffic to legacy. The modern services are reachable only through their staging-slot URLs and internal health probes (/health/ready, /health/live, /health/startup). Azure Database for PostgreSQL is provisioned and the EF Core migrations are applied, but production data has not yet been loaded.
Estimated duration: 2–3 weeks (provisioning + smoke testing). Risk: low — no production traffic touches the modern stack.
Phase 2 — Activate the data-synchronization bridge
A Debezium connector is configured against the legacy PostgreSQL write-ahead log to capture mutations on the eight watched tables (a_gift_batch, a_gift, a_gift_detail, a_motivation_group, a_motivation_detail, a_recurring_gift_batch, a_recurring_gift, a_recurring_gift_detail). Change events flow through Azure Service Bus — a session-ordered topic, persistent, with dead-lettering enabled. The session key is (ledger_id, batch_id), ensuring every event for a given batch arrives at the consumer in FIFO order. A bulk-ETL snapshot of existing data loads first; the background-thread consumer in Gift Processing API then drains the queue and applies events to the modern PostgreSQL schema via Entity Framework Core with natural-key idempotency.
The APIM policy that performs traffic shifting is parameterized on a sample APIM policy fragment; the canonical form is shown below in the dark BR theme.
<!-- APIM policy: per-endpoint traffic shift for Gift Processing -->
<policies>
<inbound>
<base />
<choose>
<!-- Read-only endpoints: serve from modern once Phase 3 lands -->
<when condition="@(context.Request.Method == "GET" && context.Request.Url.Path.StartsWith("/api/gift-processing/v1/batches"))">
<set-backend-service base-url="https://petra-gift-api.azurewebsites.net" />
</when>
<!-- Default: legacy Mono / .asmx until each write stage is migrated -->
<otherwise>
<set-backend-service base-url="https://petra-legacy.openpetra.org" />
<rewrite-uri template="/serverMFinance.asmx" />
</otherwise>
</choose>
<set-header name="X-Petra-Routing-Source" exists-action="override">
<value>@(context.Request.Url.Path)</value>
</set-header>
</inbound>
<backend><base /></backend>
</policies>
# Debezium connector config (legacy Petra PostgreSQL WAL -> Azure Service Bus)
name: petra-gift-cdc
connector.class: io.debezium.connector.postgresql.PostgresConnector
plugin.name: pgoutput
database.hostname: legacy-petra-db.example.org
database.dbname: petra_prod
slot.name: petra_gift_slot
publication.name: petra_gift_publication
table.include.list: public.a_gift_batch,public.a_gift,public.a_gift_detail,public.a_motivation_group,public.a_motivation_detail,public.a_recurring_gift_batch,public.a_recurring_gift,public.a_recurring_gift_detail
key.converter: org.apache.kafka.connect.json.JsonConverter
value.converter: org.apache.kafka.connect.json.JsonConverter
transforms: unwrap,routeBySession
transforms.unwrap.type: io.debezium.transforms.ExtractNewRecordState
transforms.routeBySession.type: org.apache.kafka.connect.transforms.RegexRouter
transforms.routeBySession.regex: .*
transforms.routeBySession.replacement: petra.gift.cdc.v1
# Azure Service Bus sink emits each event with sessionId = ${ledger_id}.${batch_id}
// Concho domain event envelope (sage-domain-event-v1) — emitted by the translator
{
"eventId": "01J8K3Z9R7VYM2QH3F4XW2C6T8",
"eventType": "petra.gift.gift_detail.upserted.v1",
"eventTime": "2026-06-14T09:21:07.412Z",
"source": "petra-legacy/serverMFinance.asmx",
"sessionId": "43.1047",
"naturalKey": {
"ledger_id": 43,
"batch_id": 1047,
"gift_transaction_number": 1,
"detail_number": 1
},
"payload": {
"motivation_group_code": "SUPPORT",
"motivation_detail_code": "FIELD",
"gift_transaction_amount": 3000.00,
"gift_amount_in_base_currency": 2580.00,
"currency_code": "EUR",
"exchange_rate_to_base": 0.86,
"tax_deductible_pct": 100.00
}
}
Estimated duration: 3–4 weeks (connector configuration, snapshot ETL, reconciliation tooling, alerting). Risk: medium — the bridge must demonstrably catch up to the live legacy database before any traffic is shifted.
Phase 3 — Migrate read-only endpoints
APIM policies begin routing read-only gift-processing requests to the modern API: gift-batch listings, gift detail queries, motivation group/detail lookups, donor gift history, recurring-gift batch queries, and exchange-rate lookups. Reads are safe to migrate first because they have no side effects on the source of truth (still legacy). APIM uses URL-path matching (e.g., GET /api/gift-processing/v1/batches → modern; everything else → legacy) with canary stepping 10% → 25% → 50% → 100%. Application Insights monitors error rate, p99 latency, and response-content diff (sampled) between legacy and modern; rollback is a single APIM policy revert.
Estimated duration: 2–3 weeks (canary progression with bake periods). Risk: low — reads are side-effect-free; rollback is < 1 minute.
Phase 4 — Migrate write endpoints in risk-ordered stages
Write endpoints migrate in five risk-ordered stages. Each stage has its own validation gate; the next stage cannot start until the previous gate has held green for a defined bake period.
| Stage | Operations | Risk | Validation Gate | Bake |
|---|---|---|---|---|
| 4a | Motivation group / motivation detail CRUD (reference data) | Low | Row count + column-level checksum reconciliation legacy ↔ modern | 1 week |
| 4b | Gift batch creation, gift entry, gift detail entry (unposted only) | Medium | Batch-total reconciliation, sequential-numbering audit (no gaps, no duplicates), 48-hour parallel-run sample comparison | 2 weeks |
| 4c | Gift batch posting (GL bridge), financial-period operations | High | GL trial-balance reconciliation against legacy, period-end close verification, full regression test suite | 3 weeks |
| 4d | Gift reversal / adjustment, bank statement import (CAMT/MT940/CSV/ZIP), recurring-gift processing (SEPA mandates) | High | Reversal audit-trail verification, bank-feed reconciliation, SEPA mandate validation against a sample of live mandates | 3 weeks |
| 4e | Annual receipt generation (Receipt Generation Service) | Medium | Receipt PDF byte-for-byte comparison on a 100-donor sample, tax-compliance spot-check by Finance Ops | 1 week |
Once a write endpoint moves to modern, the data synchronization bridge for the affected tables reverses direction or is shut off, depending on whether any legacy paths still read those tables. The reversal pattern is documented per stage in Appendix B's implementation roadmap.
Estimated duration: 10–12 weeks (sum of stages and bake periods). Risk: rises from low to high across the stages; the bake periods are the controls.
Phase 5 — Verify full synchronization
After stages 4a–4e complete and the modern stack owns all gift-processing writes, the team runs a full reconciliation: row-count parity across all eight target tables, checksum comparison on gift-batch totals by period, sample-row diffs on 100 randomly selected gift detail records (transaction amount, base amount, motivation codes, tax-deductible percentage), Application Insights confirmation that consumer lag is ≤ 0 and error rate is < 0.1%, and a full Playwright regression suite against the modern Angular SPA.
Estimated duration: 1–2 weeks. Risk: low — the reconciliation is fact-finding, not state-changing.
Phase 6 — Decommission legacy Gift Processing endpoints
When Finance Ops confirms operational confidence (a business decision, not a technical deadline): APIM policies are updated to route 100% of gift-processing traffic to the modern services; legacy serverMFinance.asmx gift-processing operations are deprecated (HTTP 301 redirect, then HTTP 410 Gone after a defined sunset window); the CDC bridge is stopped and the queue drained and removed; the legacy Mono/FastCGI server stays online for unmigrated subsystems (Partner, GL, Reporting, System Management) until their own modernization cycles complete; the Angular 20 SPA becomes the sole gift-processing interface.
Estimated duration: 2 weeks (sunset window + telemetry confirmation). Risk: low if Phase 5 held green.
Temporal view — Figure 11.2
sequenceDiagram
participant U as Users
participant APIM as Azure API Management
participant L as "Legacy Petra
(.asmx / Mono)"
participant CDC as Debezium CDC
participant SB as "Azure Service Bus
(session-ordered)"
participant M as Modern .NET 10 API
participant DB as Azure PostgreSQL
Note over U,DB: Phase 1 — Deploy modern services (shadow mode)
U->>APIM: All gift processing requests
APIM->>L: 100% routed to legacy
Note right of M: Modern services deployed
but receive no production traffic
Note over U,DB: Phase 2 — Activate data-synchronization bridge
L->>CDC: Debezium tails PostgreSQL WAL
CDC->>SB: Domain events (sessionId = ledger.batch)
Note right of SB: Snapshot ETL +
ongoing CDC capture
SB->>M: Background-thread consumer drains queue
M->>DB: Apply with natural-key idempotency
Note over U,DB: Phase 3 — Migrate read-only endpoints
U->>APIM: GET /api/gift-processing/v1/batches
APIM->>M: Reads to modern (canary 10→25→50→100%)
U->>APIM: POST /serverMFinance.asmx (writes)
APIM->>L: Writes still to legacy
Note over U,DB: Phase 4 — Migrate writes in stages (4a–4e)
U->>APIM: POST /api/gift-processing/v1/batches
APIM->>M: Stage 4b: batch creation to modern
M->>DB: Modern is source of truth for this stage
Note right of L: Bridge reverses direction
for migrated tables
Note over U,DB: Phase 5 — Verify synchronization
Note right of DB: Row counts, checksums,
batch totals reconciled
Consumer lag = 0
Note over U,DB: Phase 6 — Decommission legacy gift processing
U->>APIM: All gift processing requests
APIM->>M: 100% to modern
Note over L: serverMFinance.asmx gift ops return 301/410
Note over CDC: CDC bridge stopped
Note over SB: Queue drained and removed
Figure 11.2 — Classical strangler-fig cutover sequence for Petra Gift Processing. Phases 1–2 establish infrastructure; Phase 3 shifts reads; Phase 4 shifts writes in five risk-ordered stages (4a–4e); Phases 5–6 verify and decommission.
11.4 Why Ordering and Idempotency Matter for Gift Processing
Gift processing is a financially ordered domain: the sequence in which mutations arrive determines the correctness of downstream outcomes — GL postings, tax-deductible receipts, donor statements, and the regulator-facing audit trail. The data-synchronization bridge must deliver per-batch events in FIFO order, and the consumer must enforce idempotency. The worked example below shows why, using realistic figures from MultiCurrencyGiftProcessingRule (confidence 0.83) and GiftBatchSequentialNumbering (confidence 0.95).
Worked example — multi-currency gift with split tax-deductibility
A donor (partner key 43000127) makes a EUR 5,000 gift to a UK non-profit whose base currency is GBP. The gift is entered in legacy batch #1047 (ledger 43, period 2026/03), with exchange rate 0.86 GBP/EUR snapshotted at batch creation. The donor wants the gift split across two motivations with different tax-deductibility percentages.
| Event | Operation | Detail |
|---|---|---|
| E1 | Create gift batch #1047 | Ledger 43, EUR batch, exchange rate 0.86 GBP/EUR, period 2026/03 |
| E2 | Create gift in batch #1047 | Donor partner 43000127, gift_transaction_number = 1 |
| E3 | Create gift detail 1 (SUPPORT motivation) | EUR 3,000 (GBP 2,580), tax deductible 100%, account SUPPORT-01, cost centre 0300 |
| E4 | Create gift detail 2 (KEYMIN motivation) | EUR 2,000 (GBP 1,720), tax deductible 0%, account KEYMIN-01, cost centre 0300 |
| E5 | Post batch #1047 | GL entries: debit Bank EUR 5,000; credit SUPPORT EUR 3,000; credit KEYMIN EUR 2,000 |
What breaks if events replay out of order:
- E5 arrives before E3 / E4. The batch post finds the batch and the gift but no gift details. Per
GiftBatchSequentialNumberingand the posting state machine, the post either fails (and is retried indefinitely until E3 / E4 arrive — head-of-line blocking) or, worse, posts a zero-amount batch to GL. If E3 and E4 then arrive after a zero-amount post, the modern GL trial balance permanently disagrees with the legacy GL trial balance by EUR 5,000 for period 2026/03 — a Finance-Ops-visible reconciliation break. - E4 arrives before E2. The gift detail insert references a gift (transaction number 1) that does not yet exist in the modern database. The insert fails on the natural-key constraint; if naively retried after E2 arrives, the detail's
detail_numbermay be claimed by a parallel insert, producing detail-numbering drift. The annual receipt for partner 43000127 would then list the wrong detail order. - E1 replayed twice without idempotency. The second replay attempts to increment
LastGiftBatchNumbera second time (perGiftBatchSequentialNumbering, confidence 0.95), creating a phantom batch #1048 in modern that does not exist in legacy. Every subsequent batch number is then off by one between systems — an unrecoverable cross-system identity drift that breaks reconciliation for every future batch.
What breaks for the annual tax receipt:
If E3 and E4 arrive out of order and the consumer naively writes whichever arrives first into detail_number = 1, the motivation-to-amount mapping is silently swapped: the SUPPORT motivation now carries GBP 1,720 instead of GBP 2,580, and the KEYMIN motivation carries GBP 2,580 instead of GBP 1,720. The annual tax-receipt PDF for donor 43000127 (issued by Receipt Generation Service) then reports GBP 1,720 as tax-deductible instead of GBP 2,580 — an £860 understatement on the donor's tax filing. Per MultiCurrencyGiftProcessingRule (confidence 0.83), this is exactly the dual-tracking failure mode the rule exists to prevent, and it carries regulatory-compliance consequences in jurisdictions that audit non-profit tax receipts (HMRC Gift Aid, US IRS Form 990, German § 50 EStDV).
How the bridge prevents this:
- Ordering. Azure Service Bus sessions use
(ledger_id, batch_id)as the session key. All events for batch #1047 are delivered in strict FIFO order to a single session-receiver; E1 always precedes E2, which always precedes E3 and E4, which precede E5. Cross-batch ordering is not enforced (and is not required — different batches don't share aggregate roots). - Idempotency. The consumer uses natural-key idempotency: the composite key (
ledger_id,batch_id,gift_transaction_number,detail_number) uniquely identifies every gift detail. The consumer's upsert is anINSERT ... ON CONFLICT DO UPDATEon that natural key; a replay of E3 finds the existing row and either no-ops or updates the same row with the same payload.LastGiftBatchNumberis never re-incremented by the consumer — the modern stack treats it as a read-only watermark sourced from legacy until Phase 4b's bake completes.
11.5 Proof-of-Concept Results
PoC to be validated in Phase 2 of execution. The classical strangler-fig bridge for Petra has not yet been implemented as a working proof-of-concept on this run. The v1 Concho code-and-test-generation workflow supports SAROC bridge generation end-to-end (reference: ACAS / Docker / ROSA); classical strangler-fig artifact generation — APIM policies, Debezium connector wiring, Service Bus topic/subscription provisioning, the consumer's natural-key idempotency upsert — is planned for the next workflow cycle. The Section 11 narrative, choreography, and operational thresholds in this section are complete and implementable manually following the structure above.
The Phase 2 PoC will validate, at minimum:
- APIM policy-based routing between a legacy
.asmxendpoint and the modern.NET 10Minimal API for a single read-only operation (gift-batch listing), including canary stepping and rollback. - Debezium CDC from the legacy PostgreSQL WAL to Azure Service Bus, with sessionId =
(ledger_id, batch_id). - The background-thread consumer in Gift Processing API applying domain events to the modern PostgreSQL schema with natural-key idempotency — replay-safe and re-runnable.
- Round-trip latency from legacy commit to modern DB consistency: target p99 ≤ 2 s for single-row mutations during steady state.
- Reconciliation tooling that produces row-count, checksum, and sample-row-diff reports per gift table on demand.
11.6 Operational Considerations
Dead-letter behaviour
Azure Service Bus is configured with dead-lettering enabled (deadLetter: true in the persisted coexistencePattern declaration). Messages that fail processing after 5 delivery attempts move to the DLQ rather than head-of-line blocking the main queue. DLQ messages retain the original sessionId and sequence number, so manual replay after a fix is straightforward. A dedicated Azure Function monitors the DLQ depth and raises an Application Insights alert whenever depth exceeds zero — every DLQ message is treated as an incident, not a backlog.
Queue depth and consumer-lag thresholds
| Metric | Warning | Critical | Action |
|---|---|---|---|
| Service Bus active message count | > 1,000 | > 10,000 | Warning: investigate consumer lag and per-session imbalance. Critical: page on-call, verify consumer health and PostgreSQL connection pool. |
| DLQ depth | > 0 | > 50 | Warning: any DLQ message investigated within 4 business hours. Critical: halt any in-flight stage migration, full root-cause review. |
| Consumer p99 processing latency | > 500 ms | > 2,000 ms | Warning: review query plans and EF Core change tracking. Critical: check PostgreSQL connection pool, index health, and per-session contention. |
| Oldest message age | > 5 minutes | > 30 minutes | Warning: consumer may be stalled on a specific session. Critical: restart consumer, capture per-session lag for forensics, check for a poison message. |
| Consumer lag SLO (steady state) | > 30 s | > 5 min | SLO: modern DB ≤ 30 s behind legacy commit. Suspended during Phase 2 bulk ETL; re-armed once initial queue drains to zero. |
These thresholds are calibrated to Petra Gift Processing's transaction volume: Finance Ops at a typical non-profit handles tens to low hundreds of gift batches per day, not thousands. A queue depth of 1,000 represents a significant backlog. Organizations running year-end campaigns or disaster-relief drives should scale thresholds proportionally and re-baseline before each Phase 4 stage.
Consumer lag SLO
Target: during steady-state coexistence (Phases 3–5), modern PostgreSQL is no more than 30 seconds behind the legacy database for any committed transaction, measured as the difference between the legacy WAL commit timestamp and the consumer's ack timestamp, exported via OpenTelemetry custom metrics to Application Insights. During Phase 2's initial bulk ETL the SLO is suspended; it re-arms once the consumer drains the initial backlog to zero.
Cutover rollback plan
Every phase has an independent rollback lever. The APIM routing policy and the App Service slot-swap are independent and can be used together or separately.
| Phase | Rollback Mechanism | Data Impact | Duration |
|---|---|---|---|
| Phase 3 (reads) | Revert APIM routing policy to send reads to legacy | None — reads are side-effect-free | < 1 minute (single policy swap) |
| Phase 4a–4e (writes) | Revert APIM routing policy for the affected stage; re-enable CDC bridge legacy → modern for affected tables | Writes accepted by modern during the rolled-back stage must reverse-sync to legacy or be discarded within the validation bake window | < 5 minutes (policy swap + bridge reconfiguration) |
| Phase 6 (decommission) | Re-enable legacy serverMFinance.asmx endpoints in APIM; restart CDC bridge |
Writes that occurred only in modern after decommission require reconciliation back to legacy if rollback extends past the sunset window | < 15 minutes (service restart + bridge activation) |
App Service slot-swap provides an additional safety net: if the modern API exhibits errors after any write-stage migration, the staging slot (running the previous version) can be swapped back into production in seconds — independent of the APIM policy state. Combined, the two levers let Finance Ops respond to a surprise within minutes regardless of which side of the bridge it appeared on.
12. How Concho Enabled This Analysis
This analysis demonstrates three distinct approaches: (1) Concho-powered analysis where agents have deep insight into all subsystems via Concho MCP, (2) Claude Code alone — analyzing code with only Read/Grep/Glob, without Concho’s pre-computed knowledge — and (3) Traditional manual review by architects. Every comparison in this section includes all three so the value of Concho is clear relative to both AI-without-Concho and manual effort.
Section 12 sits at the end of the report deliberately. The earlier sections argued the modernization on its merits — the target architecture in Section 4, the platform unlocks in Section 5, the technical-debt classification in Section 6, the UI / code / data examples in Sections 7–8 and 10, the 14 behavioral rules in Section 9, and the classical strangler-fig coexistence design in Section 11. By the time a reader reaches this section, the analysis has already paid off; the question is no longer “is this credible?” but “how did anyone produce this depth on a 572,757-line .NET codebase in hours rather than weeks?” The answer is the rest of this section.
12.1 Analysis at a Glance
Petra (OpenPetra) comprises 572,757 lines of code across 1,396 cataloged files, 27 business-function subsystems, and 32 technology subjects — a layered, n-tier .NET Framework 4.7 application with a jQuery + Bootstrap 4 web client, ASP.NET Web Services (.asmx) over Mono FastCGI, an XML-driven code-generated data access layer, and a multi-database abstraction over PostgreSQL, MySQL, and SQLite. Concho’s Context Graph transformed this heterogeneous C# / JavaScript / SQL / XML surface into structured, queryable intelligence — enabling the planning workflow to evaluate all 27 subsystems across 3 independent scoring runs, select Finance – Gift Processing as the modernization pilot through a 2-of-3 consensus, validate a 2-service Azure App Service target through 3-perspective consensus (composite score 7.8/10), reconcile 9 platform-affinity entries across 3 independent runs (8 ELIMINATE / 1 HYBRID), audit 28 dependencies across 2 manifests with 7 architectural concerns classified, and extract 14 behavioral rules with confidence scores 0.70–0.95 and 5 corroborated by legacy NUnit tests — all from pre-computed analysis rather than file-by-file reading.
| Dimension | Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|---|
| Time to Insight | Minutes to hours — structured queries return pre-analyzed intelligence instantly. Candidate selection scored all 27 subsystems across 3 independent runs in roughly 77 Concho queries (~25 minutes of agent wall-clock); behavioral-rule extraction consumed 24 Concho calls including 9 source-code verifications; the run-004 pipeline ran approximately ~135 Concho calls across all phases, dominated by LLM composition rather than data retrieval. | Days to weeks — must read C# files, follow TWebConnector-style RPC method dispatch, parse the petra.xml code-generation chain, build a mental model from scratch. Multi-layer code generation makes naive file reads misleading: generated *.Generated.cs files outweigh hand-written code, XML report templates (43,521 LOC) inflate file counts without representing translation work. |
Weeks to months — module walkthroughs, OpenPetra contributor interviews, NAnt build inspection, sample-payload tracing through Mono FastCGI, multi-currency rule walkthroughs with the finance team, SEPA mandate-format verification with banking specialists. |
| Codebase Coverage | 100% — all 572,757 cataloged lines analyzed across 1,396 files; full architecture tree with 8 root layers (Application 116,775 LOC, Presentation 64,648 LOC, Data 48,033 LOC, Cross-Cutting 40,804 LOC, Infrastructure 36,977 LOC, Build & Deployment 34,112 LOC, Test 26,849 LOC, Integration 12,885 LOC) and line counts per node. Every business function, technology subject, and integration point enumerated. | ~5–15% — sampling constrained by context window and session time; roughly 50–200 files out of 1,396 before the analyst must stop and synthesize. When context windows are exceeded, LLMs tend to compensate by making things up and silently introducing hallucinations. | ~1–5% — selective review of MFinance/Gift/, MPartner/, and a sampling of js-client/src/forms/Finance/Gift/. The 43.5K LOC of XML report templates and 21 SQL files in the gift slice are typically not reviewed. |
| Subsystems Evaluated | All 27 — every business function scored on Risk / Feasibility / Strategic Value with quantitative inputs (file counts, entity inventories, cross-subject affinity scores, capability lists). The 3-run consensus protocol applied three different emphasis lenses (Standard, Technical Feasibility, Business Value) and reached 2-of-3 agreement on Finance – Gift Processing (+2.47 weighted average). Run 1 (risk-aversion) selected Finance – Banking and is preserved as a documented Plan B. | 3–5 — limited by time to read each subsystem’s web connector classes, typed-dataset XML, and front-end JS controllers. Cannot compare all 27 within a practical session. | 1–2 — typically the candidate the customer suggests plus one contrast (e.g., Finance – Accounting as the “too big” alternative). |
| Confidence Scoring | Quantitative (0.0–1.0) per entity. Behavioral-rule extraction: 14 rules with confidence range 0.70–0.95, mean GWT confidence 0.78, 9 rules at ≥ 0.75. DonationManagement bounded context at 0.84 confidence. TGiftTransactionWebConnector entry point at 0.93. Concho architectural assessment LLM confidence 0.85. Three non-idempotent write rules (GiftBatchSequentialNumbering 0.95, GiftBatchFinancialPeriodValidation 0.90, MultiCurrencyGiftProcessingRule 0.83) are the auditable evidence chain behind the strangler-fig selection. |
None — assertions about which web connector matters most, or which typed dataset is load-bearing, without quantified support. Cannot distinguish a 0.95-confidence rule from a 0.63-confidence one (GiftAdjustmentProcessing workflow is the example — correctly downgraded to a secondary scenario by Concho). | Qualitative (“high / medium / low”) based on reviewer experience with .NET Framework codebases. No reproducible scoring methodology. |
| Repeatability | Deterministic — the same Concho queries produce the same results; an auditable query trail exists for every statistic in this report. The 3-run candidate-selection protocol specifically tests stability: 2 of 3 lenses converged on Finance – Gift Processing; the divergent run was preserved with full rationale. The 3-run platform-affinity protocol achieved 100% consensus (5 of 9 entries fully consensus, 4 of 9 majority, 0 split decisions). | Variable — depends on which .cs / .js / .xml files are sampled and in what order. The multi-database abstraction is invisible unless multiple connection-factory files happen to be read together. Different sessions reach different conclusions. |
Low — different reviewers reach different conclusions, especially about coexistence strategy (strangler-fig vs lift-and-shift) and about which slice to modernize first. Knowledge walks out the door with the consultant. |
| Integration Discovery | 151 integration points mapped across the full codebase with protocol classification — 83 bidirectional (55%), spanning .asmx RPC dispatch, web connector method invocation, multi-DB factory calls, file-based integrations, and database integrations. 148 entry points enumerated (47 REST, 25 SOAP). The integration data validated the 2-service seam: Gift Processing API owns interactive operations, Receipt Generation Service handles PDF generation, connected via a single one-way REST contract (GET /api/gift-processing/v1/donors/{partnerKey}/posted-gifts). |
Explicit method calls in sampled files only. Misses implicit integration through code generation (where petra.xml drives generated DAL files at NAnt build time) and through the typed-dataset abstraction (where GiftBatchTDS mediates between web connector and database). Cannot produce a project-wide integration map. |
Stakeholder interviews plus selective code review — depends on contributor knowledge of how THttpConnector.CallWebConnector dispatches across modules. Often incomplete because the implicit integration through code generation is not documented in architecture diagrams. |
| Behavioral Rules | 14 distinct rules extracted for the Finance – Gift Processing slice: 7 business rules + 7 implementation constraints. Each rule has a confidence score, a Given-When-Then specification, a verified file:line reference, a category (Validation: 4, Calculation: 3, State Transition: 2, Workflow: 3, Authorization: 2), and a platform-behavior-change assessment. 12 of 14 rules transfer with no behavioral change; 1 (BR-GIFT-002 Financial Period Validation) is a deliberate improvement; 1 (BR-GIFT-006 Confidential Gift Privacy) requires explicit authorization-policy mitigation. 5 rules are corroborated by existing NUnit tests in csharp/ICT/Testing/lib/MFinance/server/Gift/*.test.cs. |
Could read individual Gift.*.cs files and identify some rules, but cannot produce a complete deduplicated catalog with confidence scoring. The cross-file manifestation pattern (e.g., GiftBatchSequentialNumbering enforced in Gift.Batch.cs but referenced by the migration-execution coexistence design) is invisible without the entity-level data. |
Behavioral rules surface piecemeal in code review or post-incident retrospectives. The discipline of enumerating the rules before modernization begins, with confidence scoring and platform-behavior-change assessment, is rarely applied; rules are rediscovered during modernization when something breaks. |
12.2 Depth of Understanding
Beyond speed, the three approaches differ fundamentally in the depth and completeness of understanding they produce. This subsection compares what each approach actually achieves for six critical analysis capabilities.
12.2.1 Architecture Mapping
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
Complete layered-architecture tree with line counts per layer: Application Layer at 116,775 LOC (Finance Management 61,632 LOC of which Gift Processing is 17,103 LOC), Presentation Layer at 64,648 LOC (Report Templates 43,521 LOC), Data Layer at 48,033 LOC (Database Schema 24,925 LOC — the petra.xml master), and 5 more root layers. 48 aggregate roots across 34 bounded contexts enumerated with confidence scores. The DonationManagement bounded context (confidence 0.84) provided the domain evidence for the 2-service split. The Concho-vended architecture_layer_diagram artifact (confidence 0.95) was embedded verbatim in Section 3.1; the data_flow_diagram artifact (confidence 0.88) was embedded in Section 3.6. |
Can determine architecture of individual files by reading them, but cannot classify all 572K lines across 1,396 files. Would sample directory structure (csharp/ICT/Petra/Server/, csharp/ICT/Petra/Shared/, js-client/, XmlReports/) and infer the layered shape. Likely misses the multi-database abstraction pattern because it spans PetraServerAdmin, the connection factory, and three database-specific provider classes in different directories. Cannot produce line counts per layer without reading every file. |
Creates architecture diagrams from contributor knowledge and existing wiki pages. Often reflects the intended N-tier shape rather than the actual shape, where Presentation is dominated by 43.5K LOC of report templates that don’t fit cleanly into any tier. May not discover that the Application Layer alone contains 116,775 lines dominated by Finance Management. |
12.2.2 Subsystem Classification
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
| All 27 business functions identified and profiled: file count, entity inventory, domain concepts, capabilities, key implementation classes, and cross-subject affinity scores. Finance – Gift Processing profiled with 279 files, 7 business rules, 7 constraints, 3 workflows, 4 entry points, 4 integration boundaries, and a single bounded context (DonationManagement). Cross-subject affinity scores (Gift Processing ↔ Donations Processing at 0.8, Gift Processing ↔ Finance – Accounting at 0.6) materially informed the slice-shape decision and the recommended modernization sequence (Phase 1: Gift Processing, Phase 2: Banking, Phase 3: Accounting). | Could identify subsystems from csharp/ICT/Petra/Server/lib/M* directory names, but quantifying each subsystem’s complexity (entity count, workflow count, integration boundaries) requires reading every web connector class. Likely identifies 5–8 major subsystems (MFinance, MPartner, MPersonnel, MConference, MSponsorship, MReporting, MSysMan) and stops there. Cannot produce the cross-subject affinity scores needed to recognize that Gift Processing and Donations Processing share 0.8 affinity — overlapping code surfaces under different cluster labels. |
Relies on contributor knowledge of the OpenPetra module map. Long-tenured contributors know the major modules but often disagree on boundaries (e.g., whether “Donations Processing” is part of Finance or its own subsystem). Quantified profiling of all 27 subsystems would require weeks of dedicated architect time. |
12.2.3 Integration Analysis
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
151 integration points mapped across the full codebase with protocol classification (83 bidirectional). 148 entry points enumerated (47 REST, 25 SOAP). For the Gift Processing slice specifically, Concho returned 4 HTTP-routable entry points (TGiftTransactionWebConnector, AnnualReceiptGenerationInterface, GiftBatchManagementInterface, BankImportManagementInterface) — the evidence that a request-routing surface exists, which directly ruled out SAROC and selected the middle branch (classical strangler-fig) of the coexistence decision tree. The integration_map artifact (confidence 0.95) and entry_point_catalog artifact (confidence 0.88) were both consumed verbatim. |
Can trace explicit method calls in files it reads, but cannot detect implicit integration through code generation (where petra.xml drives generated DAL files at NAnt build time) or through the typed-dataset abstraction. Would need to read the NAnt build files, the XML schema, the parser (TDataDefinitionParser), and several web connectors together to understand the generation chain. The 148 entry points would take days to enumerate manually. |
Integration maps created from contributor interviews and architecture documents. Often incomplete because the SOAP-vs-JSON-RPC distinction is not documented — contributors who write web connectors know how to invoke them, but the wire-format detail (JSON content despite SOAP envelope) is rarely articulated. The implicit code-generation integration is the most commonly missed pattern. |
12.2.4 Code Quality Assessment
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
7 architectural concerns surfaced with severity ranking (1 high, 4 medium, 2 low) and 0.85 LLM confidence. Each concern classified against the target architecture: 3 retired by target, 2 mitigated by target, 2 out of scope for this slice. 5 architectural strengths catalogued in parallel (single-source-of-truth schema, code-generation pipeline, multi-DB abstraction, cross-layer validation framework, NUnit integration test infrastructure). 28 dependencies audited across csharp/ThirdParty/packages.config (19 NuGet) and js-client/package.json (13 npm) with lifecycle status and target-action classification: 10 removed, 10 replaced, 7 upgraded, 1 outside slice. One known-CVE entry (axios 0.21.4: CVE-2021-3749, CVE-2023-45857) flagged for removal. The tech_debt_inventory artifact (87 debt items, confidence 0.92) is the source-of-truth. |
Can assess quality of files it reads, identifying patterns like deep nesting, long methods, or magic strings. But without full-codebase context, cannot determine whether a pattern is isolated or systemic. Would not discover that 5 of the 7 concerns share a common root cause — the .NET Framework 4.7 runtime ceiling that pins all NuGet packages to pre-.NET 5 versions. Could read packages.config but lacks lifecycle context for each package (which are EOL, which have CVEs, which polyfills are absorbed by .NET 10). |
Code-quality assessment typically done through targeted module reviews. Senior contributors may know which subsystems carry the worst debt, but rarely quantify strengths alongside weaknesses. The classification of “what the target retires vs mitigates vs leaves alone” is a structured analysis that manual reviews rarely produce. |
12.2.5 Modernization Complexity Estimation
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
| All 27 subsystems scored on Risk / Feasibility / Strategic Value across 3 independent runs with different emphasis lenses, producing a 2-of-3 consensus +2.47 weighted score for Finance – Gift Processing (Risk 5.8, Feasibility 7.0, Strategic Value 9.0). The divergent lens (Run 1, risk-aversion) selected Finance – Banking and is preserved as a documented Plan B — not hidden, not overruled silently. The obvious-but-wrong picks were quantitatively rejected: Finance – Accounting (370 files, too large for a pilot), Partner – Contacts Management (211 files, central hub — modernizing it disrupts everything), System Management – Users (390 files — infrastructure plumbing with no demo punch). The platform-affinity 3-run consensus reinforced this with 100% resolution on 9 entries. | Can estimate complexity for subsystems it has analyzed, but without full-codebase profiling, cannot compare all 27. Likely evaluates the 3–5 subsystems whose directories look smallest by file count. Cannot produce quantified risk scores because it lacks the entity-level data (cross-subject affinity, capability inventory) needed for scoring. Would not recognize that the 279-file headline for Gift Processing includes report templates and SQL files that don’t represent hand-translation work. | Estimation based on experience and analogy. Typically evaluates 1–2 candidates — often the one suggested by stakeholders. The discovery that Finance – Gift Processing occupies a “meaningful but manageable” sweet spot at ~17,103 LOC of Application-Layer logic within a 279-file footprint requires the kind of architectural decomposition that manual reviews rarely produce for all 27 subsystems. |
12.2.6 Behavioral-Rule Catalog Construction
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
14 distinct rules extracted: 7 business rules and 7 implementation constraints, all with structural evidence and confidence scores 0.70–0.95 (mean GWT confidence 0.78). Concho’s entity model provided the complete set from a single subject query, with 9 source-code verifications confirming file:line references against actual code. Rules classified into 5 categories (Validation: 4, Calculation: 3, State Transition: 2, Workflow: 3, Authorization: 2) with platform-behavior-change assessment. The top rule — BR-GIFT-001 GiftBatchSequentialNumbering at 0.95 confidence — was flagged as the primary concurrency hotspot for the strangler-fig bridge, directly informing Section 11’s coexistence design. 12 of 14 rules transfer with no behavioral change; 1 (BR-GIFT-002 Financial Period Validation) is a deliberate improvement; 1 (BR-GIFT-006 Confidential Gift Privacy) requires explicit authorization middleware. 5 of 14 rules are corroborated by legacy NUnit tests — surfaced by a single search_files query that found the 7 test files in csharp/ICT/Testing/lib/MFinance/server/Gift/. |
Could read Gift.Batch.cs, Gift.Validation.cs, and TaxDeductibility.cs and identify some rules in those files. Would not find rules whose code lives in SQL files (Gift.ReceiptPrinting.GetDonationsOfDonor.sql), adjustment files (Gift.Adjustment.cs), or the schema migration (Upgrade202206_202207.cs) without explicitly searching for them. Confidence scoring is not available. The cross-file manifestation pattern — the same rule enforced in C# code, SQL queries, and report templates — is invisible without the entity-level data model. Would also miss the BR-GIFT-011 nuance that legacy tests reveal (non-UNIT partners leave caller defaults unchanged, contrary to what code-only inference suggests). |
Behavioral rules surface piecemeal in stakeholder interviews and code review, often incompletely. The discipline of producing a numbered catalog with confidence scoring and platform-behavior-change assessment before modernization begins is rarely applied. Rules are rediscovered during modernization when a regression breaks a test — or worse, when a stakeholder notices incorrect behavior in production. |
12.3 Why the Concho Context Graph Matters
Could Claude Code alone read a source file and determine legacy behavior? Sure — if it knew which file to read. But the fundamental problem is: you don’t know what you don’t know. Even if Claude Code is pointed at the right file and correctly understands the logic, it still cannot know whether that file is the only part of the overall system that touches that capability. A constraint might originate in a data structure, propagate through a print program, and manifest as a screen layout limitation — three different files, three different architectural layers. A tool analyzing one file at a time cannot see this full picture, and worse, it doesn’t know to look for it.
Consider the most architecturally significant discovery from this analysis: the non-idempotent write pattern that selected the classical strangler-fig coexistence strategy. The migration-execution agent needed to decide between symmetric dual-write and classical strangler-fig for the legacy/modern coexistence period. This decision required knowing three specific facts about write behavior across the gift-processing surface: (1) BR-GIFT-001 GiftBatchSequentialNumbering (confidence 0.95, in Gift.Batch.cs) atomically increments LastGiftBatchNumber on the ledger — a non-idempotent write that cannot be safely duplicated; (2) BR-GIFT-002 GiftBatchFinancialPeriodValidation (confidence 0.90, in Gift.Batch.cs) constrains batch dates to open accounting periods — a time-dependent validation that must be authoritative in exactly one system; (3) BR-GIFT-003 MultiCurrencyGiftProcessingRule (confidence 0.83, in Gift.Currency.cs) computes dual base + transaction amounts from an exchange-rate snapshot that is not stable across systems.
These three facts live in three different C# source files. No single file contains all the evidence. A file-at-a-time analysis would need to (a) know to look at batch creation, period validation, and currency conversion as a coordinated set, (b) find the specific lines in each file, and (c) synthesize the pattern: “these writes are non-idempotent across three independent enforcement points, ruling out symmetric dual-write.” Concho’s Context Graph had already classified all three rules with confidence scores and source locations. The planning agent assembled the evidence in two Concho queries (get_entities_by_subject for entry points + get_entities_by_subject for business rules) from pre-computed intelligence, not by hoping to read the right files in the right order.
The advantage of Concho’s Context Graph is that the planning agent instantly has access to the full gamut of business flows, behavioral rules, architecture layers, and integration points — at both business and technology levels — across 100% of the codebase. It does not start by reading files; it starts by knowing what exists. It retrieves actual source code if and only if needed to verify an assertion the Concho analysis has already made. This inverts the workflow from “read code and hope you find what matters” to “know what matters and verify it against the code.”
The Inversion in Practice
Claude Code Alone = Start with 1,396 files across C#, JavaScript, SQL, and XML. Read some. Grep for patterns. Build a mental model. Hope you find the three non-idempotent write enforcement points before committing to a coexistence strategy. Rediscover from scratch on every project.
Concho + Claude Code = Start with 27 classified business-function subsystems, 34 bounded contexts, 48 aggregate roots, 215 project-wide business rules (14 specific to the gift slice with confidence scores), 151 integration points, 148 entry points (47 REST + 25 SOAP), and a 7-row architectural-concerns catalog — all with confidence scores and source references. Query what you need. Verify against code only when necessary. The coexistence-strategy decision draws on pre-computed rule evidence, not file sampling.
12.4 Specific Examples from This Analysis
12.4.1 Three-Run Subsystem Consensus Across 27 Candidates
Concho’s candidate-selection step evaluated all 27 business-function subsystems across 3 independent runs, each applying a different emphasis lens: Run 1 (Standard Weighted Scoring), Run 2 (Technical Feasibility emphasis), and Run 3 (Business Value emphasis). Two of three runs converged on Finance – Gift Processing as the optimal pilot — a 2/3 consensus. Run 1 selected Finance – Banking on risk-aversion grounds; that result is documented as Plan B in Appendix A rather than overruled silently. The 3-run protocol consumed approximately 77 Concho queries collectively (~25 minutes of agent wall-clock), each run independently profiling subsystems through domain briefings, entity inventories, and architectural-insight queries.
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
| 27 subsystems evaluated across 3 independent scoring runs with consensus averaging. Finance – Gift Processing selected at consensus score +2.47 (Risk 5.8, Feasibility 7.0, Strategic Value 9.0). The protocol exposed scoring stability and surfaced a legitimate minority opinion: Run 1 (risk-aversion lens) selected Banking and that result is preserved as documented Plan B. The runner-up Finance – Accounting (370 files, highest risk) was quantitatively rejected as too large for a pilot. | Would evaluate 3–5 subsystems by reading directory contents and web-connector files. Cannot run the same subsystem set through multiple emphasis lenses because each profiling pass requires hours. Likely defaults to picking the customer-named candidate without quantitative comparison. The discovery that two reasonable lenses can disagree — and the discipline to preserve the minority position rather than hide it — is structurally outside what file-at-a-time analysis can produce. | Evaluates 1–2 candidates, typically with qualitative pros-and-cons. Multiple reviewers may disagree on which candidate is best; a consensus protocol is not standard practice. The quantified scoring framework (Risk × -0.4 + Feasibility × 0.3 + Strategic Value × 0.3) with entity-level inputs is a capability that manual reviews rarely achieve. |
12.4.2 Three-Perspective Service Architecture Validation
The service-architecture phase did not simply assert “2 services.” It submitted the Gift Processing slice to three independent analytical perspectives — Domain-Driven Design (bounded context analysis), Technical (load pattern and resource profile), and Business Capability (user roles and operational cadence) — then scored the options on a 4-dimension rubric. All three lenses unanimously proposed 2 services; the composite weighted score was 7.8/10, exceeding the 7.0 quality gate. This phase consumed only 2 Concho calls because it built on the already-profiled DonationManagement bounded context (confidence 0.84) from the candidate-assembly phase.
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
| Three independent perspectives evaluated with pre-computed bounded-context data (DonationManagement at 0.84 confidence, exactly one context), workflow complexity data (DonationReceiptGeneration at 0.87, RecurringGiftProcessing at 0.84, GiftAdjustmentProcessing at 0.63), and integration-boundary data. The DDD perspective scored 7.9, Technical 7.7, Business 7.9 — convergence is genuine, not a procedural tiebreaker artifact. Five open questions from the prior target-architecture phase were resolved unanimously (service count = 2; Receipt Generation = API-only access; async = optional; Bank Import = internal; Motivation Admin = internal). | Could read enough code to recognize that gift processing and receipt generation have different characteristics, but cannot produce three formal perspectives with quantified bounded-context, workflow, and integration data. Would likely propose a service count based on intuition or analogy to other microservice projects, without a quality-gate scoring rubric. | Service decomposition decisions typically made via whiteboard sessions weighing “scalability,” “team boundaries,” and “deployment isolation” — valid factors, but without the structured 3-perspective protocol. The unanimous 2-service convergence across DDD, Technical, and Business lenses is the kind of structural agreement that is uniquely reproducible via pre-computed intelligence. |
12.4.3 Platform Affinity Reconciliation from 3-Run Consensus
Section 5’s platform affinity analysis ran 3 independent passes and reconciled the results into 9 consensus entries (8 ELIMINATE, 1 HYBRID, 0 PRESERVE). Five of nine entries had full 3/3 inclusion-and-classification consensus; four of nine had 2/3 majority. Zero entries had a split decision requiring human review — 100% resolution rate. Five additional entries that appeared in only 1 of 3 runs were explicitly omitted with documented rationale. This consensus-then-reconcile pattern — the same approach used for candidate selection — ensures that the platform constraints surfaced for stakeholders are structurally stable, not artifacts of a single analytical perspective.
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
9 platform affinity entries reconciled from 3 independent runs: 5 with full consensus, 4 with majority agreement, 0 unresolved. Classification disputes resolved with documented rationale (e.g., Mono FastCGI: 3/3 ELIMINATE on classification, 2/3 on category — resolved to Processing not Capacity because the underlying constraint is a synchronous request-affinitized hosting bridge with [ThreadStatic] isolation that blocks async-await, not a capacity ceiling). Entries span 4 categories: Capacity (1), Processing (3), UI (3), Data Type (2). Concho data provided the architectural-concern evidence (7 concerns at 0.85 confidence) and implementation-constraint evidence (226 constraints) underlying each entry. |
Could identify some platform constraints by reading code (e.g., jQuery DOM manipulation in js-client/, SOAP envelope in .asmx files), but cannot produce a structured 3-run consensus. Would list constraints without the ELIMINATE / HYBRID / PRESERVE classification framework. The distinction between platform constraints and business rules — critical for knowing what to retire vs preserve — requires entity-level data that file reading cannot provide. |
Platform constraints surface during modernization planning as blockers or assumptions. The structured categorization (Capacity / Processing / UI / Data Type) with ELIMINATE / HYBRID / PRESERVE classification is a capability that manual reviews develop ad hoc rather than systematically. Disagreements between reviewers about what is “platform” vs “business” are common and often unresolved. |
12.4.4 Evidence-Based Coexistence Pattern Selection
The migration-execution phase did not default to “strangler fig because everyone uses strangler fig.” It evaluated the pattern against specific evidence from just 2 Concho queries: (1) get_entities_by_subject(Finance - Gift Processing, entry_point) returned the 4 HTTP-routable entry points — confirming a request-routing surface exists, ruling out SAROC and selecting the middle branch of the decision tree; (2) get_entities_by_subject(Finance - Gift Processing, business_rule) returned the 7 rules including the three non-idempotent ones at 0.95 / 0.90 / 0.83 confidence — ruling out symmetric dual-write. The classical strangler-fig pattern was the only branch left, and the evidence chain is auditable.
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
Classical strangler-fig selected with evidence chain: 4 HTTP-routable entry points (TGiftTransactionWebConnector, AnnualReceiptGenerationInterface, GiftBatchManagementInterface, BankImportManagementInterface) confirm request-routing surface; 3 non-idempotent write rules (confidence 0.83–0.95) rule out dual-write; batch boundaries (posted/unposted) confirm incremental cutover feasibility. Azure API Management routing specified with posted/unposted state machine as the strangler key. Debezium CDC over PostgreSQL WAL selected for data synchronization with Azure Service Bus as the session-ordered durable substrate. Each fact traces to a specific Concho entity with confidence score. |
Would likely recommend strangler fig by default (it is the standard pattern for web-service modernizations), but without evidence that the writes are non-idempotent or that the routing surface is HTTP-routable. Cannot enumerate the entry points to confirm that traffic shifting is feasible. Might recommend dual-write without realizing that the sequential batch numbering makes it unsafe. | Strangler-fig selection typically based on architectural intuition and industry precedent. The specific evidence chain (non-idempotent writes, batch boundaries, routing surface) is the kind of analysis a senior architect would perform if asked, but is rarely produced proactively. The Debezium-with-Azure-Service-Bus selection requires knowledge of both the legacy database configuration and the target deployment topology. |
12.4.5 Use Case Discovery from Entity Evidence
The use-case-discovery phase discovered 3 primary use cases (Gift Batch Entry through Posted GL; Annual Tax-Deductible Receipt Generation; Recurring Donation Cycle with SEPA Export) plus a documented secondary scenario (Posted Gift Adjustment, downgraded because its workflow confidence is 0.63 — below the 0.70 primary-use-case threshold). Each primary use case was selected on multi-criterion fit: cross-service span, business visibility, modernization delta, demo-ability, and rule density. All 14 catalog rules fire across these three primary use cases (Use Case 1 fires 8 of 14; Use Case 2 fires 6 of 14; Use Case 3 fires 7 of 14). This discovery consumed 13 Concho calls: 4 get_entities_by_subject queries for workflows / entry points / bounded context / integration boundaries, then 9 targeted entity lookups for workflow steps, bounded-context responsibilities, and entry-point operations.
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
| 3 primary use cases discovered from Concho workflow entities, each scored on 5 criteria. The DonationReceiptGeneration workflow (9-step state machine, HIGH complexity, confidence 0.87) was correctly selected as the cross-service demonstration because it is the only use case exercising the REST contract between the two target services. The GiftAdjustmentProcessing workflow (5 steps, confidence 0.63) was correctly downgraded to a secondary scenario because its confidence falls below the 0.70 threshold — a quantitative decision that file-at-a-time analysis cannot replicate. Each use case was mapped to UI screens (5 per use case), behavioral rules in execution order, and service-boundary annotations. | Could identify the gift receipt generation workflow by reading Gift.Receipting.cs and the associated SQL, but cannot score it against alternatives without reading all workflow-related files. Cannot determine that Use Case 1 fires 8 of 14 behavioral rules without first building the behavioral-rules catalog — a circular dependency that Concho resolves by providing both the workflow and rule inventories simultaneously. Would likely miss the GiftAdjustmentProcessing workflow entirely (or include it without realizing its confidence is too low). |
Use case selection typically based on stakeholder input (“the annual receipt run is our biggest pain point”). The quantified scoring against alternatives, with rule-density and cross-service-span metrics, is a structured analysis approach that manual reviews rarely produce. |
12.4.6 Legacy-Test Discovery for High-Fidelity Rule Corroboration
A subtle but important capability of the Concho Context Graph is that it surfaces legacy test files alongside production source. The behavioral-rules phase ran a single search_files query against csharp/ICT/Testing/lib/MFinance/server/Gift/ and discovered 7 legacy NUnit test files covering the gift slice (PostGiftBatch.test.cs, RevertAdjustGiftBatch.test.cs, SingleGiftReceipt.test.cs, AnnualReceipts.test.cs, RecurringGiftBatch.test.cs, SetMotivationGroupAndDetail.test.cs, and one fixture). Cross-referencing these against the 14 catalog rules upgraded 5 rules from code-inferred to code-inferred-test-corroborated — raising their effective confidence and providing first-priority test-translation targets for the downstream code-and-test-generation workflow.
Critically, the legacy test suite for BR-GIFT-011 (Motivation Detail Assignment by Partner Class) captured a piece of original intent that code alone leaves implicit: for a non-UNIT partner the motivation is left unchanged — a "do nothing" branch that the production code expresses only as the absence of an overwrite. The original developers asserted that outcome explicitly in a test. Harvesting it gives a second, independent record of intent that nails down exactly these edge and do-nothing cases — which is precisely why Concho reads the surviving tests alongside the source.
| Concho + Claude Code | Claude Code Alone (no Concho MCP) | Manual Review |
|---|---|---|
One search_files query enumerated 7 legacy NUnit test files in scope. Cross-reference against the rule catalog upgraded 5 rules to code-inferred-test-corroborated, with mean confidence rising from 0.83 (code-only) to 0.87 in the test-corroborated subset. BR-GIFT-011 nuance (non-UNIT partner default behavior) discovered through test assertions rather than production code. |
Would need to know that legacy tests exist (often invisible from production paths), guess the test naming convention, and read each test file to extract its assertions. Cannot systematically cross-reference test assertions against a behavioral-rule catalog because no such catalog exists in this approach. | Legacy tests are sometimes preserved in modernization projects, often discarded as “tied to the old platform.” The discipline of mining them as a fidelity-of-intent source for the behavioral-rule catalog is rare. Stakeholder interviews rarely surface the BR-GIFT-011 partner-class nuance because it is too low-level to make it into requirements documents. |
12.5 Concho Query Snippets
For transparency, here are representative queries the planning agents executed against Concho’s Context Graph (project key petra, cycle 23). The Concho Context Graph exposes a small set of focused tools; the agents compose them to assemble evidence.
Project intelligence — one query returns the top-level shape:
get_project_metadata(project_key="petra", cycle=23)
→ { total_loc: 572757, file_count: 1396,
primary_language: "C# / .NET Framework 4.7",
business_function_subjects: 27,
technology_subjects: 32,
data_stores: ["PostgreSQL", "MySQL", "SQLite"] }
Subsystem profile — one query returns 31+ entities for a single slice:
get_subject_profile(project_key="petra", cycle=23,
subject="Finance - Gift Processing",
subject_kind="business_function")
→ { file_count: 279,
bounded_contexts: [{ name: "DonationManagement", confidence: 0.84 }],
business_rules: 7,
implementation_constraints: 7,
workflows: 3,
entry_points: 4,
integration_boundaries: 4,
affinities: { "Donations Processing": 0.8,
"Finance - Accounting": 0.6 } }
Coexistence pattern evidence — two queries collapse weeks of file-reading:
get_entities_by_subject(subject="Finance - Gift Processing",
entity_type="entry_point")
→ 4 HTTP-routable entry points (TGiftTransactionWebConnector,
AnnualReceiptGenerationInterface, GiftBatchManagementInterface,
BankImportManagementInterface)
⇒ request-routing surface exists ⇒ SAROC ruled out
get_entities_by_subject(subject="Finance - Gift Processing",
entity_type="business_rule")
→ 7 rules including:
BR-GIFT-001 GiftBatchSequentialNumbering (conf 0.95) - atomic ++
BR-GIFT-002 GiftBatchFinancialPeriodValidation (conf 0.90) - period bind
BR-GIFT-003 MultiCurrencyGiftProcessingRule (conf 0.83) - FX-snap
⇒ writes non-idempotent ⇒ symmetric dual-write ruled out
Result: classical-strangler-fig (middle branch of the decision tree)
Behavioral-rule extraction — one subject query returns the full catalog:
get_entities_by_subject(subject="Finance - Gift Processing",
entity_type="business_rule") +
get_entities_by_subject(subject="Finance - Gift Processing",
entity_type="implementation_constraint") +
get_entities_by_subject(subject="Donations Processing",
entity_type="business_rule")
→ 14 rules total (7 + 7 + 1 merged with GiftReceiptEligibility)
confidence range 0.70-0.95, mean GWT confidence 0.78
search_files(file_name_regex=
"csharp/ICT/Testing/lib/MFinance/server/Gift/.*\\.test\\.cs")
→ 7 legacy NUnit test files
⇒ 5 of 14 rules upgraded to code-inferred-test-corroborated
12.6 The Numbers
This run-004 analysis consumed approximately ~135 Concho Context Graph queries across all phases. Those queries provided structured intelligence covering 100% of the 572,757-line, 1,396-file codebase — every business function, every integration point, every behavioral rule, every architectural concern. The alternative would have been reading thousands of files and hoping to find what matters.
| Phase | Concho Queries | Key Deliverables | Estimated Manual Equivalent |
|---|---|---|---|
| Project Intelligence (Sections 1 + 3) | ~11 | 572K LOC profile, 27 business functions, 48 aggregate roots, 34 bounded contexts, 151 integration points, 17 Concho-vended artifacts catalogued | 2–3 weeks |
| Candidate Selection (3 runs + assembly) | ~77 | All 27 subsystems scored on Risk / Feasibility / Strategic Value with 2-of-3 consensus; Plan B documented | 6–10 weeks |
| Target Architecture (Section 4) | 1 | 2-service Azure App Service design, 8-table data model, resolved target patterns (reused validated facts from prior phases) | 1–2 weeks |
| Service Architecture (Appendix B + 4.5) | 2 | 3-perspective unanimous consensus (DDD / Technical / Business), composite 7.8/10 quality gate, REST contracts, 5 open questions resolved | 1–2 weeks |
| Platform Affinity (3 runs + reconciliation) | 0 (*) | 9 platform entries reconciled from 3-run consensus (8 ELIMINATE / 1 HYBRID / 0 PRESERVE), 100% resolution rate | 1–2 weeks |
| Tech Debt Analysis (Section 6) | 8 | 7 architectural concerns classified, 28 dependencies audited (1 known-CVE), 5 strengths catalogued | 1–2 weeks |
| Use Case Discovery | 13 | 3 primary use cases + 1 documented secondary scenario, all 14 rules mapped, UI storyboards per case | 1–2 weeks |
| Behavioral Rules (Section 9) | 24 | 14 rules with confidence 0.70–0.95, GWT specs, source verification, 5 corroborated by legacy NUnit tests | 2–4 weeks |
| Migration Execution (Section 11) | 2 | Classical strangler-fig selected with evidence chain, Debezium CDC + Azure Service Bus, 6-phase cutover choreography | 1–2 weeks |
| Total | ~135 | Complete modernization plan: 12 report sections + 3 appendices | 16–29 weeks |
(*) Platform-affinity runs consumed 0 direct Concho queries this cycle because they reused already-cataloged technology-subject and architectural-concern data from prior phases. This is a feature, not a gap: once a phase has surfaced the structural facts, downstream agents reason over them rather than re-query.
The estimated manual equivalent of 16–29 weeks represents the effort an experienced architect or modernization consultant would spend to produce an analysis of comparable depth and coverage — profiling all 27 subsystems, building the complete behavioral-rule catalog, mapping all integration points, auditing all dependencies, and designing the target architecture with evidence-based service decomposition. The Concho-assisted analysis achieved this in a fraction of the time, with higher coverage (100% vs sampling), quantified confidence scoring, and full auditability.
12.7 Conclusion: From Partial Analysis to Full Deep Insight
The Petra Finance – Gift Processing analysis demonstrates a fundamental transformation in legacy-modernization capability. Traditional approaches — whether manual or AI-assisted — are constrained to partial analysis: sampling representative files, inferring patterns from incomplete coverage, and accepting knowledge gaps as inevitable. Claude Code alone can accelerate this partial analysis but remains bound by context limits that force selective coverage (~5–15% of large codebases). When context windows are exceeded, LLMs tend to compensate by making things up and silently introducing hallucinations into the output.
Claude Code + Concho MCP eliminates partial analysis entirely. By providing pre-analyzed semantic intelligence across 100% of the codebase — business functions, architecture layers, behavioral rules, integration points, and confidence-scored relationships — Concho’s Context Graph transforms modernization from educated guesswork into systematic engineering. The difference isn’t just speed (~135 queries vs 16–29 weeks of architect time); it’s completeness, accuracy, and the discovery of critical patterns — like the three-file non-idempotent write evidence that selected the strangler-fig pattern — that partial analysis would miss entirely.
Every statistic in this report traces to a Concho query result. Every assertion about the legacy codebase is backed by structural evidence, not file sampling. Every comparison across subsystems covers all 27 candidates, not a convenient subset. This is what Concho’s Context Graph enables: not just faster analysis, but fundamentally more complete analysis — the kind that catches the non-idempotent write pattern before you commit to a coexistence strategy, finds all 14 behavioral rules before you start translating code, and evaluates all 27 subsystems before you pick the pilot.
Ready to Transform Your Legacy Modernization?
Experience the difference between partial sampling and full deep insight. Concho’ Concho Context Graph can analyze your legacy codebase — .NET Framework, Java, COBOL, C++, or other languages — and deliver comprehensive modernization intelligence in hours, not weeks.
Visit concho.ai and sign up for a pilot project. Discover what you’ve been missing with partial analysis.
Appendices
Advanced analysis and deployment artifacts
Appendix A: Multi-Agent Subsystem Selection
This appendix documents the multi-agent consensus methodology used to select the modernization pilot candidate from among 27 documented business-function subsystems in Petra (OpenPetra). Three independent evaluation runs were conducted, each applying the same weighted scoring formula but from a different analytical perspective, and the results were reconciled to produce a consensus recommendation. Unlike a unanimous outcome, the three runs of this analysis surfaced a real disagreement — Run 1 selected Finance — Banking on a lower-risk argument, while Runs 2 and 3 selected Finance — Gift Processing. This appendix shows both cases honestly and explains why the 2/3 majority prevails.
A.1 Methodology Overview
Subsystem selection for a modernization pilot is a multi-dimensional problem: technical feasibility, business impact, and migration risk pull in different directions. A single analyst’s evaluation inevitably reflects their professional bias — a developer may favor the technically cleanest subsystem, while a business stakeholder may push for the highest-revenue module regardless of migration complexity. Multi-agent consensus addresses this by running three independent evaluations with different emphasis lenses, then reconciling the results.
Each evaluation run uses the same weighted scoring formula but applies a different qualitative emphasis when assessing sub-factors within each dimension. The three runs are structurally independent: each agent queries Concho MCP tools separately, constructs its own subsystem profiles, and produces its own ranked list without knowledge of the other runs’ results. Consensus is determined after all three runs complete: if 2 or more runs agree on the top candidate, that is the recommendation; if all 3 differ, a weighted average across runs is used.
For this analysis, Runs 2 and 3 selected Finance — Gift Processing as the top candidate, while Run 1 selected Finance — Banking. The 2/3 majority makes Gift Processing the consensus recommendation. The divergence is informative rather than a defect of the methodology — it shows that the next-best candidate (Banking) is a credible alternative under a stronger risk-aversion lens, and it gives stakeholders a documented Plan B if pilot constraints later shift toward minimizing first-slice risk.
A.2 Scoring Formula
Score = (Risk × −0.4) + (Feasibility × 0.3) + (Strategic Value × 0.3)
Risk is negatively weighted because lower risk is better. The theoretical maximum score is +6.00 (Risk=0, Feasibility=10, Strategic Value=10). All three dimensions are scored on a 0–10 scale.
A.3 Scoring Dimensions
| Dimension | Weight | Direction | Sub-Factors |
|---|---|---|---|
| Risk | 40% | Lower is better | Business criticality, integration fan-out, data migration risk, compliance/regulatory exposure |
| Feasibility | 30% | Higher is better | File count and LOC, complexity, dependency isolation, test coverage, code-generation regularity |
| Strategic Value | 30% | Higher is better | Business value, pattern reusability across the programme, stakeholder visibility, skill building |
A.4 Agent Perspectives
Run 1: Standard Weighted Scoring
The first evaluation applies balanced weighting across all three dimensions without emphasizing any particular perspective. It queries Concho for project metadata, all 27 business function subjects, and detailed subject profiles for the top candidates. This run establishes the baseline ranking and stress-tests whether the highest-value candidate is also the safest first slice.
Key optimization: Finding the candidate that best balances all three dimensions simultaneously, with no single dimension dominating the assessment.
Run 2: Technical Feasibility Emphasis
The second evaluation applies heightened scrutiny to feasibility sub-factors: file count sweet spots, code-generation regularity, bounded-context confirmation, integration boundary count, and target-stack alignment. This run stress-tests whether the top candidate is genuinely tractable as a first slice or just strategically attractive.
Key optimization: Ensuring the recommended candidate is technically achievable within pilot constraints, with clean domain boundaries and manageable scope.
Run 3: Business Value Emphasis
The third evaluation weights the strategic-value sub-dimensions (business value, visibility, pattern reusability) more heavily in qualitative assessment. This run ensures the selected pilot delivers maximum organizational impact and establishes modernization patterns that transfer to the largest possible portion of the remaining codebase.
Key optimization: Maximizing the business return and pattern-reusability payoff of the pilot investment.
A.5 Agent Analysis Results
Run 1 Results: Standard Weighted Scoring — Selected Finance — Banking
Run 1 Recommendation (DIVERGENT): Finance — Banking
Risk: 4.0 | Feasibility: 8.0 | Strategic Value: 8.0 | Weighted Score: +3.20
Run 1 ranked Finance — Banking first with a +3.20 weighted score, ahead of Gift Processing (+1.70). Run 1's argument centers on first-slice risk minimization:
- Risk (4.0): Banking is well-bounded with low integration fan-out (only 1 high-affinity neighbor: Finance — Accounting at 0.7). Its failure mode is "manual reconciliation falls back to operator", not "donations break". Run 1 scored Gift Processing's risk at 7 because it is the revenue heart of the system — any bug breaks core donor stewardship and tax compliance.
- Feasibility (8.0): 177 files — second-smallest Finance sub-domain. Clear bounded context (CAMT/MT940/CSV bank statement import and matching). Modern equivalents are well-understood (ISO 20022 parsing libraries exist for .NET 10). SEPA compliance logic is portable.
- Strategic Value (8.0): Touches three modernization-pattern areas in one pilot: file-format parsing, transaction matching algorithms (
Matching.csis 47.8KB / 1126 LOC), and external standards integration (ISO 20022, SEPA, MT940). Builds reusable skills for harder slices later.
Run 1's argument against Gift Processing: Gift Processing is the revenue heart of OpenPetra. Migrating it first is high-risk — any bug breaks core donor stewardship and tax compliance. Gift Processing also has higher integration fan-out (0.8 affinity to Donations Processing, 0.6 to Accounting, plus deep MPartner coupling) making the strangler-fig boundary harder to draw cleanly. Banking sits downstream of Gift Processing (bank statements get matched into gift batches), so Banking can be modernized first with a synchronous API/file handoff back into the legacy gift module, giving the cleanest possible strangler boundary.
Top 5 from Run 1:
| Rank | Subsystem | Files | Risk | Feasibility | Strategic Value | Score |
|---|---|---|---|---|---|---|
| 1 | Finance — Banking | 177 | 4 | 8 | 8 | +3.20 |
| 2 | Hospitality — Accommodation* | 5 | 2 | 9 | 4 | +3.10 |
| 3 | Sponsorship — Child Management* | 12 | 3 | 9 | 5 | +3.00 |
| 4 | Finance — Budgeting | 160 | 3 | 8 | 5 | +2.70 |
| 5 | Finance — Gift Processing | 279 | 7 | 6 | 9 | +1.70 |
* Run 1 rejected these as meaningful pilots due to insufficient scale (5–12 files won't exercise enough patterns).
Run 2 Results: Technical Feasibility Emphasis — Selected Finance — Gift Processing
Run 2 Recommendation: Finance — Gift Processing
Risk: 5.0 | Feasibility: 7.5 | Strategic Value: 8.5 | Weighted Score: +2.80
Run 2 confirmed Gift Processing as the top candidate under its technical-feasibility lens. Key findings:
- Risk (5.0): Moderate. Donation processing is mission-critical but the gift-batch workflow is well-bounded (posted/unposted states, batch-level posting). Integration with MFinance GL is direct but the contract is narrow (batch posting). Multi-currency and tax-receipt calculation adds regulatory exposure but is contained.
- Feasibility (7.5): 279 files — large but tractable. The implementation surface is concentrated in
TGiftTransactionWebConnector+TGiftBatchFunctions+ theAGift/AGiftBatch/AGiftDetailtables — three well-named seams. Typed dataset ORM is mechanically translatable to EF Core. The web connector RPC pattern maps cleanly to ASP.NET Core controllers. The posted/unposted batch state machine gives the strangler a natural routing key. - Strategic Value (8.5): Patterns proven here (typed-dataset to EF Core, ASMX to ASP.NET Core, jQuery to Angular reactive forms, batch posting workflow) are directly reusable for Finance — Accounting, Finance — Budgeting, and Finance — Banking — the three highest-affinity neighbors. Tax-receipt HTML templating exercises the PdfSharp cross-platform migration risk early.
Run 2 explicitly considered four smaller alternatives (Sponsorship — Child Management, Reporting — Custom Reports, Conference — Registration, Personnel — Staff Management) and rejected them as too niche or too cross-cutting to anchor a modernization story. Gift Processing's 279-file size was acknowledged as the principal feasibility headwind, but the implementation is concentrated in a small set of named classes — the file count overstates the cognitive surface.
Top 5 from Run 2:
| Rank | Subsystem | Files | Risk | Feasibility | Strategic Value | Score |
|---|---|---|---|---|---|---|
| 1 | Finance — Gift Processing | 279 | 5.0 | 7.5 | 8.5 | +2.80 |
| 2 | Conference — Registration | 70 | 3.5 | 8.0 | 6.0 | +2.80 |
| 3 | Finance — Banking | 177 | 5.5 | 7.0 | 7.5 | +2.15 |
| 4 | Conference — Event Management | 77 | 4.0 | 7.5 | 6.0 | +2.05 |
| 5 | Sponsorship — Child Management | 12 | 3.0 | 9.0 | 5.5 | +2.05 |
Note: Conference — Registration ties Gift Processing on raw score (+2.80) but loses the tiebreaker; modernizing the donation engine is the headline story for a non-profit ERP, not an event-management form.
Run 3 Results: Business Value Emphasis — Selected Finance — Gift Processing
Run 3 Recommendation: Finance — Gift Processing
Risk: 5.5 | Feasibility: 7.5 | Strategic Value: 9.5 | Weighted Score: +2.90
Run 3's business-value lens produced the strongest strategic case for Gift Processing. Key findings:
- Donor-facing visibility: Of all 27 subsystems, gift batches and tax receipts are the operational heartbeat of a non-profit. Demoing this modernized first lets sponsors prove ROI to boards before broader rollout — the strongest possible strategic narrative.
- Pattern reusability: Gift Processing exercises the four "spine" patterns the rest of the modernization needs — typed DataSet ORM, code-generated DAL, ASMX web service, AngularJS jQuery form. Once those are cracked for Gift, Donations Processing (0.8 affinity), Banking (0.5), Accounting (0.6), and Financial Statements all reuse the same pipeline.
- Boundary clarity: The Gift module has crisp seams (
TGift*WebConnector,AGift*tables, motivation_group codes) — exactly what a strangler-fig migration with the FK-constraint-drop policy needs. - Right-sized scope: 279 files is large enough to be meaningful but small enough to finish in a defensible timeline; not a 370-file Accounting or 390-file Users boil-the-ocean exercise.
Run 3 explicitly evaluated Donations Processing (raw score +2.95) and Sponsorship — Program Management (+3.20) as higher-scoring alternatives but rejected both on grounds of substantive overlap with Gift (Donations is effectively the same target via shared files and 0.8 affinity) and insufficient scale (Sponsorship at 12 files is niche and depends on Gift's recurring-gift infrastructure to be meaningful).
Top 5 from Run 3:
| Rank | Subsystem | Files | Risk | Feasibility | Strategic Value | Score |
|---|---|---|---|---|---|---|
| 1 | Finance — Gift Processing | 279 | 5.5 | 7.5 | 9.5 | +2.90 |
| 2 | Donations Processing* | 279 | 5.0 | 7.5 | 9.0 | +2.95 |
| 3 | Sponsorship — Program Management* | 12 | 4.0 | 8.5 | 7.5 | +3.20 |
| 4 | Reporting — Custom Reports* | 20 | 4.0 | 8.5 | 6.5 | +2.90 |
| 5 | Finance — Budgeting | 160 | 5.0 | 7.5 | 7.5 | +2.50 |
* Disqualified as pilot: Donations Processing substantively overlaps Gift (sibling slice, not independent); Sponsorship and Custom Reports are too small (12–20 files) or too cross-cutting to validate the modernization framework.
A.6 Consensus Analysis
Where the Three Runs Agree
- Finance — Gift Processing and Finance — Banking are the two real options. All three runs ranked both subsystems inside their top 5. Run 2 had Banking at #3 (+2.15); Run 3 had Banking at #6 in its broader table (+1.60). Every run agrees these are the two credible first slices.
- Tiny modules are not pilots. All three runs noted that Sponsorship — Program Management (12 files), Sponsorship — Child Management (12 files), and Hospitality — Accommodation (5 files) achieve high raw scores via trivially low risk but would not validate a modernization approach at enterprise scale.
- Finance — Accounting is wrong for a first slice. All three runs scored Accounting in the lower half (Run 1: +0.30; Run 2: +0.35; Run 3: +1.20) due to its 370 files and deep integration web. Right for Phase 3, wrong for a pilot.
- System Management subsystems are last. All three runs scored Settings (352 files) and Users (390 files) below zero or barely positive due to security criticality and system-wide coupling.
Where the Three Runs Diverge
| Factor | Run 1 (Standard) | Run 2 (Feasibility) | Run 3 (Business Value) | Impact |
|---|---|---|---|---|
| Top recommendation | Finance — Banking (+3.20) | Finance — Gift Processing (+2.80) | Finance — Gift Processing (+2.90) | 2 of 3 runs select Gift Processing; Run 1 selects Banking on a lower-risk argument. |
| Gift Processing risk score | 7.0 | 5.0 | 5.5 | Run 1's risk reading is 2 points higher than Runs 2 and 3. This single 2-point swing accounts for most of the Gift score difference (8.0 vs 2.80–2.90 weighted impact). |
| Banking risk score | 4.0 | 5.5 | 6.5 | Inverse pattern: Run 1 reads Banking as low-risk (well-bounded standards-based parsing); Runs 2 and 3 read it as moderate-risk (SEPA/MT940/ISO 20022 standards parsing adds complexity). |
| Gift Processing strategic value | 9.0 | 8.5 | 9.5 | All three runs agree Gift Processing carries the highest strategic value in the system. Run 1 does not dispute this — it argues the risk premium outweighs the value premium for a first slice. |
| Banking strategic value | 8.0 | 7.5 | 7.5 | All three runs put Banking strategic value clearly below Gift, but within reach. Banking demonstrates external-standards integration patterns that are reusable elsewhere. |
How the Divergence Was Resolved
The divergence is qualitative, not arithmetic — it reflects a genuine difference in risk-aversion philosophy between Run 1 and Runs 2/3. Run 1 reads Gift Processing's revenue centrality as a reason not to migrate it first (failure blast radius is too large); Runs 2 and 3 read the same fact as a reason to migrate it first (highest demonstration value, patterns flow downstream to the rest of MFinance).
The consensus methodology resolves this by majority rule: 2 of 3 runs select Gift Processing, so Gift Processing is the recommendation. Three reinforcing reasons support the majority:
- Run 1's risk premium is large but not decisive. Run 1 scores Gift Processing's risk at 7.0, two points above Runs 2 and 3. Even taking Run 1's risk reading at face value, Gift Processing's strategic-value advantage (+1.0 to +1.5 over Banking) and pattern-reusability premium offset most of the risk gap. The strangler-fig migration strategy (selected separately in the architecture plan) is specifically designed to contain blast radius via the batch-state seam — Run 1's risk argument is partially mitigated by the chosen migration pattern.
- Banking is a tactical pilot; Gift is a strategic pilot. Run 1's argument is correct that Banking is the safer first slice in isolation. But Banking's value is mostly tactical (proves bank-format parsing works). Gift Processing's value is strategic (proves the entire modernization spine works on the system's marquee workflow). The latter is what a pilot needs to demonstrate.
- Banking remains the credible Phase 2 target. All three runs rank Banking inside their top 6. Selecting Gift first does not abandon Banking — it sequences Banking as the natural Phase 2 follow-on, where the patterns proven on Gift accelerate the Banking slice.
When would Run 1's recommendation prevail? If pilot constraints shift toward strict first-slice risk minimization — for example, if the customer's board requires a low-blast-radius proof before authorizing a Gift Processing migration — the orchestrator can defer to Run 1 and pilot Banking first. The Run 1 case is documented credibly in section A.5 above precisely so that decision can be made transparently if needed.
A.7 Combined Three-Run Score Matrix — Top Candidates
Average scores across the three independent runs for the candidates that appeared in at least one Top 5.
| Subsystem | Files | Run 1 | Run 2 | Run 3 | Average | Verdict |
|---|---|---|---|---|---|---|
| Finance — Gift Processing | 279 | +1.70 | +2.80 | +2.90 | +2.47 | RECOMMENDED (2/3 consensus) |
| Finance — Banking | 177 | +3.20 | +2.15 | +1.60 | +2.32 | Run 1 winner; Phase 2 target |
| Finance — Budgeting | 160 | +2.70 | +1.35 | +2.50 | +2.18 | Mid-pack; not a pilot |
| Conference — Registration | 70 | +2.00 | +2.80 | +1.95 | +2.25 | Niche domain; rejected |
| Donations Processing | 279 | +1.10 | +2.00 | +2.95 | +2.02 | Sibling of Gift; same target |
| Sponsorship — Child Management | 12 | +3.00 | +2.05 | +3.05 | +2.70 | Too small for pilot |
| Sponsorship — Program Management | 12 | +3.00 | +2.80 | +3.20 | +3.00 | Too small for pilot |
| Hospitality — Accommodation | 5 | +3.10 | +3.05 | +2.70 | +2.95 | Too small for pilot (5 files) |
A.8 Bottom-Tier Subsystems (Aggregated Across Runs)
Subsystems that all three runs scored low and rejected as pilot candidates:
| Subsystem | Files | Average Score | Why Rejected |
|---|---|---|---|
| Finance — Accounting | 370 | +0.62 | Largest subsystem; GL spine; deep integration web; right for Phase 3, wrong for pilot. |
| Partner — Contacts Management | 211 | +1.07 | Foundational entity; every other module joins on PPartner. Migrate after pilot patterns are proven. |
| Partner — Persons | 210 | +1.23 | Foundation table; high blast radius. |
| Partner — Organizations | 203 | +1.07 | Partner-merge complexity; high coupling. |
| Reporting — Financial Statements | 201 | +1.42 | Reads from GL; modernize after Finance core. |
| System Management — Settings | 352 | −0.30 | Cross-cutting infrastructure; security-sensitive; do not pilot. |
| System Management — Users | 390 | −0.18 | Auth/security; broad coupling; defer. |
| System Management — Access | 58 | +0.55 | libsodium/scrypt security surface; defer. |
A.9 Final Recommendation
Consensus Recommendation: Finance — Gift Processing
Consensus: 2 of 3 runs agree (majority — Runs 2 and 3); Run 1 selected Finance — Banking as a lower-risk alternative
| Risk | Feasibility | Strategic Value | Weighted Score | |
|---|---|---|---|---|
| Run 1 (Standard) — selected Banking | 7.0 | 6.0 | 9.0 | +1.70 |
| Run 2 (Feasibility) — selected Gift | 5.0 | 7.5 | 8.5 | +2.80 |
| Run 3 (Business Value) — selected Gift | 5.5 | 7.5 | 9.5 | +2.90 |
| Three-Run Average (Gift Processing) | 5.8 | 7.0 | 9.0 | +2.47 |
279 files | concentrated in TGiftTransactionWebConnector, TGiftBatchFunctions, AGift/AGiftBatch/AGiftDetail | high-affinity neighbors: Donations Processing (0.8), Finance — Accounting (0.6)
Recommended Modernization Sequence
- Phase 1 (Pilot): Finance — Gift Processing — Establishes modernization patterns on the marquee donor workflow. Validates strangler-fig approach via the posted/unposted batch-state seam. Proves the typed-dataset to EF Core, ASMX to ASP.NET Core, jQuery to Angular 20 spine on the system's most visible capability.
- Phase 2: Finance — Banking — Natural extension; bank imports feed gift batches. Leverages all patterns from Phase 1, plus adds external-standards integration (ISO 20022, SEPA, MT940). All three runs ranked Banking in the top 6; Run 1 ranked it first. The 2/3 majority defers Banking to Phase 2 but acknowledges Run 1's case that Banking is the safer first slice in isolation — the orchestrator can swap Phases 1 and 2 if customer constraints later demand a lower-blast-radius pilot.
- Phase 3: Finance — Accounting — The largest and most complex subsystem (370 files). Tackled after Phases 1–2 have de-risked the modernization patterns and built team confidence.
- Subsequent phases: Partner subsystems, Reporting, then System Management (deferred to last due to security sensitivity and system-wide coupling).
Risk Mitigation Strategies for the Recommended Pilot
Run 1's risk-premium argument against Gift Processing is partially addressed by the specific mitigations baked into the architecture plan and target stack:
- Strangler-fig boundary at the WebConnector layer:
TGiftTransactionWebConnectoralready provides an API facade. The new ASP.NET Core 10 service fronts this interface while the legacy system continues to handle GL posting and Partner lookups, containing blast radius. - Batch-boundary rollback: Gift processing operates in discrete batches (posted/unposted). If the modernized system encounters issues, the legacy batch-processing path remains available at the batch boundary. Each posted batch is an atomic recovery point.
- FK-constraint-drop slice policy: The architecture plan's drop-constraints policy on slice boundaries means the Gift slice can be carved out of the shared PostgreSQL schema without entangling the entire MFinance schema, addressing Run 1's "deep MPartner coupling" concern.
- Run 1 documented as fallback: If pilot constraints shift to demand strict first-slice risk minimization, the architecture is set up to swap Phases 1 and 2 and pilot Banking instead. The Run 1 case is documented credibly above to make that pivot transparent.
A.10 Benefits of Multi-Agent Approach (Demonstrated by This Run)
- Surfaces real disagreement. A unanimous outcome is comforting but uninformative. This analysis produced a real 2-vs-1 split with a substantive argument behind the minority position. The methodology forced that disagreement into the open rather than letting a single analyst's framing prevail unexamined.
- Quantifies a defensible majority decision. The 2/3 consensus on Gift Processing is supported by two independent analytical lenses (feasibility, business value); Run 1's dissent is documented but does not change the recommendation.
- Produces a credible Plan B. Run 1's analysis is high-quality and worth preserving. If pilot constraints shift, the orchestrator has a fully-scored alternative ready to deploy rather than restarting candidate selection from scratch.
- Reduces single-analyst bias. The three runs disagreed precisely because they applied different lenses — that's the methodology working as designed, not a defect.
- Reproducible methodology. The same three-run process applies to subsequent modernization phases or different codebases.
A.11 Concho Query Usage Across All Runs
| Query Category | Run 1 | Run 2 | Run 3 | Purpose |
|---|---|---|---|---|
| Project Metadata | 1 | 1 | 1 | Project-level scale, language, LOC, domain distribution |
| Business Function Enumeration | 1 | 1 | 1 | Enumerate all 27 subsystems |
| Subject Profile Deep-Dives | 18 | 22 | 22 | File counts, capability summaries, affinity scores per subsystem |
| File Discovery (legacy approach) | 9 | — | — | Pre-switch to get_subject_profile for compact data |
| Totals | 29 | 24 | 24 | 77 Concho queries across 3 runs |
Comparison to Manual Approach
Without Concho, this three-run candidate selection would require a senior architect to manually review 572,757 lines of C#/XML/JS across the OpenPetra codebase, identify subsystem boundaries via using-statement archaeology, count files per module, and assess integration affinities by reading source — estimated at 3–5 engineer-days per run for partial coverage, or 9–15 engineer-days total for all three runs. With Concho, all three runs completed in approximately 25 minutes total (77 MCP queries, each returning pre-computed intelligence from the Concho Context Graph), with richer signal (cross-subject affinity scores, capability summaries, integration boundaries) than manual code archaeology could produce in a planning timeframe.
Appendix B: Target Service Architecture
B.1 Methodology
Service granularity for the Finance — Gift Processing subsystem was determined by running three independent architecture analyses in parallel, each applying a different lens to the same source-of-truth inputs (Section 4 fragment, the target-architecture handoff, and Concho codebase intelligence for cycle 23). Each lens produced a service-count recommendation, a service decomposition, and a self-assessed score against the four-dimension service-architecture rubric defined in the modernization-scoring skill (Operational Complexity 20%, Business Alignment 30%, Technical Soundness 30%, Change Velocity 20%).
| Lens | Primary Concern | Inputs Emphasized |
|---|---|---|
| Domain-Driven Design | Bounded context boundaries, aggregate cohesion, invariant ownership | Concho bounded-context query (DonationManagement, confidence 0.84), aggregate roots from the data model, consistency-boundary analysis |
| Technical Architecture | Code coupling, scalability characteristics, deployment independence | File clustering across 279 source files, peak-load profiles, blast-radius analysis for releases |
| Business Capability | User workflows, organizational ownership, value streams | Concho capability profile, the five Angular SPA screens, organizational-owner mapping (finance ops vs. compliance/tax) |
B.2 Lens 1 — Domain-Driven Design Proposal
Concho cycle 23 confirms that the Gift Processing subsystem contains exactly one bounded context, DonationManagement, at confidence 0.84. A naive reading would conclude one bounded context implies one service. DDD as a discipline does not equate the two: within a single context there can be multiple aggregate clusters with different invariants, lifecycles, and consistency boundaries. The DDD analysis therefore looked one level deeper, at aggregate cohesion.
B.2.1 Aggregate Cluster Analysis
| Aggregate Cluster | Aggregate Roots | Invariant Type | Lifecycle |
|---|---|---|---|
| Gift Lifecycle | GiftBatch (+ Gift, GiftDetail), RecurringGiftBatch (+ RecurringGift, RecurringGiftDetail), MotivationGroup / MotivationDetail | Transactional ACID. GiftBatch state machine (Unposted → Posted → Cancelled) is the strangler-fig routing key. Posting is atomic. | Mutable while unposted; immutable once posted. Daily activity. |
| Receipt Issuance | AnnualReceipt, ReceiptTemplate | Idempotent document generation. One receipt per donor per tax year. Sequence numbers are append-only and auditable. | Immutable once issued; reprints carry an audit-trail self-reference. Episodic (Jan–Feb peak). |
B.2.2 DDD Decision Rationale
- Different consistency boundaries. Gift posting is transactional ACID; receipt issuance is idempotent document generation. Mixing them inflates the transactional surface.
- Different change vectors. Receipt templates and tax-jurisdiction rules churn independently from the gift batch state machine.
- Different read/write characteristics. Gift Processing is mixed read/write under user-driven load; Receipt Generation is read-heavy bursty workload concentrated at year-end.
- Document artifact lifecycle. PDFs are external artifacts with retention obligations distinct from row-level gift data.
B.2.3 Rejected DDD Alternatives
| Alternative | Why Rejected (DDD) |
|---|---|
| 1 cohesive service (one bounded context = one service) | Conflates aggregate clusters with different invariants. Posted-batch immutability and receipt-PDF immutability are different invariants that should not share a transactional surface. |
| Bank Import as a third service | Bank Import is a workflow that writes the GiftBatch aggregate root. A separate service forces cross-service writes to GiftBatch — a textbook DDD anti-pattern. |
| Motivation Administration as a third service | Motivation codes are reference data with no independent invariants. Every GiftDetail references a MotivationDetail; splitting forces a cross-service join on the hottest read path. |
| Recurring Gifts as a third service | Recurring gifts share the same GiftDetail aggregate structure and motivation references as one-off gifts. The "recurring" attribute is a frequency, not a boundary. |
DDD lens recommendation: 2 services. Self-assessed score 7.9 (OpComplexity 7, BusinessAlign 8, TechSoundness 9, ChangeVelocity 7).
B.3 Lens 2 — Technical Architecture Proposal
The Technical lens evaluated three dimensions in parallel: code coupling across the 279 source files, peak-load profiles for each capability, and deployment-independence requirements.
B.3.1 File-Clustering Analysis
| Cluster | Approx Files | Coupling to Gift Tables | Verdict |
|---|---|---|---|
TGiftTransactionWebConnector family |
~85 | High — every endpoint touches AGiftBatch/AGift/AGiftDetail | Keep together (Gift Processing API) |
TGiftBatchFunctions + date/period/posting logic |
~40 | High — tightly coupled to the GiftBatch state machine | Keep with above (Gift Processing API) |
| Annual receipt HTML templating + PDF generation | ~30 | Read-only coupling to posted gifts | Separable (Receipt Generation Service) |
| Typed-dataset boilerplate + shared utilities | ~120 | N/A — regenerated from petra.xml |
Becomes EF Core entities / shared NuGet packages; does not drive service boundaries |
B.3.2 Load-Profile Analysis
| Workload | Peak Driver | Pattern | Sizing Implication |
|---|---|---|---|
| Gift batch entry & posting | User-driven, business hours | Mixed read/write; bursty around month-end | Steady; autoscale 2–4 instances |
| Bank import parsing | Daily/weekly file drops | Bursty read+write; CPU-bound parse | Same service as Gift Processing (same write path) |
| Recurring gift cycle | Monthly/quarterly | Batched write | Same service |
| Receipt PDF generation | Year-end spike (Jan–Feb) | Read-heavy + CPU/memory for PDF rendering | Different scaling profile — warrants its own App Service that scales 1–6 instances during the year-end window |
B.3.3 Deployment Independence
Gift Processing changes carry regression risk against the posting state machine and require slot-swap validation. Receipt Generation changes are template-driven (HTML, jurisdiction-specific tax rules) with low blast radius and ship more frequently. Coupling them forces every receipt-template tweak to redeploy the transactional core — an unnecessary operational tax.
Technical lens recommendation: 2 services. Self-assessed score 7.7 (OpComplexity 7, BusinessAlign 7, TechSoundness 9, ChangeVelocity 8).
B.4 Lens 3 — Business Capability Proposal
The Business lens scored capabilities by user workflow boundaries and the organizational owner. Non-profit finance teams have two distinct jobs-to-be-done in this subsystem.
B.4.1 Jobs-to-be-Done
| Job | Cadence | Organizational Owner | SPA Screens |
|---|---|---|---|
| Job 1: Process incoming donations | Year-round, daily | Finance operations / donor services | Gift Batches, Recurring Gifts, Motivations, Bank Import |
| Job 2: Issue annual tax receipts | Year-end, episodic (Jan–Feb peak) | Compliance / tax reporting (often different humans than Job 1) | Annual Receipts |
B.4.2 Business Decision Rationale
- Different workflows, different cadences, different humans. Treating them as separate services aligns the technology to how the business actually operates.
- Brand surface. The receipt PDF is the one place in this subsystem where the non-profit's brand touches the donor directly. Coupling its release cycle to the transactional core is risky — a template hotfix during peak season should not require regression-testing the posting state machine.
- UI symmetry. The Angular UI naturally separates Annual Receipts into its own screen with its own information architecture. Backing it with its own service makes the UI/API symmetry clean and the audit story straightforward.
B.4.3 Rejected Business Alternatives
| Alternative | Why Rejected (Business) |
|---|---|
| 1 cohesive service | Possible but ties compliance team's release cadence to finance ops team's release cadence. Coupled blast radius for template changes. |
| Bank Import as a separate service | Splits a single user workflow ("import the bank file, review the batch, post it") across services and screens. Bank Import flows directly into Gift Batches. |
| Motivation Administration as a separate service | Adds a cross-service call to every gift entry save — poor UX. Administration is rare; the use of motivation codes is constant. |
Business lens recommendation: 2 services. Self-assessed score 7.9 (OpComplexity 6, BusinessAlign 9, TechSoundness 8, ChangeVelocity 8).
B.5 Cross-Lens Scoring Matrix
| Criterion | Weight | DDD | Technical | Business | Average |
|---|---|---|---|---|---|
| Operational Complexity | 20% | 7 | 7 | 6 | 6.7 |
| Business Alignment | 30% | 8 | 7 | 9 | 8.0 |
| Technical Soundness | 30% | 9 | 9 | 8 | 8.7 |
| Change Velocity | 20% | 7 | 8 | 8 | 7.7 |
| Weighted Total | — | 7.9 | 7.7 | 7.9 | 7.8 |
All three lenses score above the 7.0 quality gate. The DDD and Business proposals tied at 7.9; the Technical proposal sat 0.2 below them. Per the modernization-scoring skill tiebreaker rule (scores within 0.5 points prefer business alignment), the Business proposal wins by procedure, but the practical result is moot — all three lenses propose the same two services with the same boundaries and the same answers to the four open questions. The convergence is genuine, not procedural.
B.6 Open-Question Resolutions
| Question | DDD | Technical | Business | Consensus |
|---|---|---|---|---|
| Final service count | 2 | 2 | 2 | 2 |
| Receipt data access | API-only | API-only | API-only | API-only |
| Async pathway | Optional | On-demand only | Optional | Optional / synchronous at pilot; Service Bus available for future bulk scaling |
| Bank Import as separate service? | No | No | No | No — internal handler inside Gift Processing API |
| Motivation Admin as separate service? | No | No | No | No — internal handler inside Gift Processing API |
B.7 Target Service Specifications
B.7.1 Gift Processing API
| Attribute | Value |
|---|---|
| Purpose | Process incoming donations end-to-end — from batch entry or bank-file import, through validation and tax-deductibility calculation, to posting against the GL via the strangler-fig bridge. |
| Organizational owner | Finance Operations / Donor Services team. |
| Business capabilities | Gift batch CRUD (single + bulk); bank statement import (CAMT v053, MT940, CSV, ZIP); recurring gift management with SEPA mandates; motivation administration; posting state machine (Unposted → Posted → Cancelled); multi-currency handling; tax-deductibility percentage calculation. |
| Owned tables | gift_batch, gift, gift_detail, motivation_group, motivation_detail, recurring_gift_batch, recurring_gift, recurring_gift_detail (8 tables, per Section 4.4). |
| Upstream | Angular 20 SPA (Gift Batches, Recurring Gifts, Motivations, Bank Import screens); bank file uploads via Blob + parser pipeline. |
| Downstream | Legacy Partner Service (read-only validation via APIM strangler bridge); Legacy GL Service (one-way posting via APIM strangler bridge). |
| Azure resources | Azure App Service P1v3 (autoscale 2–4); Azure Database for PostgreSQL (Flexible Server, General Purpose 2 vCores); Azure Cache for Redis (motivation lookups, exchange rates); Azure Blob Storage (bank-file archive); Azure Key Vault; Application Insights. |
| Auth (user) | Microsoft Entra ID JWT Bearer. |
| Auth (service-to-service inbound) | Managed Identity from Receipt Generation Service, scope GiftProcessing.Read.Posted. |
| Scaling profile | Year-round steady with month-end bursts; autoscale rule: CPU > 65% sustained 5 min → scale out by 1, max 4 instances. |
B.7.1.1 REST Contract (representative endpoints)
# Gift batches
GET /api/gift-processing/v1/batches?ledgerId=&status=&year=&period=
POST /api/gift-processing/v1/batches
GET /api/gift-processing/v1/batches/{batchId}
PATCH /api/gift-processing/v1/batches/{batchId}
DELETE /api/gift-processing/v1/batches/{batchId} (only when Unposted)
# Gifts and details within a batch
POST /api/gift-processing/v1/batches/{batchId}/gifts
PATCH /api/gift-processing/v1/batches/{batchId}/gifts/{giftId}
POST /api/gift-processing/v1/batches/{batchId}/gifts/{giftId}/details
# Posting state machine (the strangler routing key)
POST /api/gift-processing/v1/batches/{batchId}/post
POST /api/gift-processing/v1/batches/{batchId}/cancel
# Bank statement import (CAMT v053, MT940, CSV, ZIP)
POST /api/gift-processing/v1/bank-imports (multipart upload)
GET /api/gift-processing/v1/bank-imports/{importId}
# Recurring gifts & SEPA mandates
GET /api/gift-processing/v1/recurring-gifts?donorPartnerKey=
POST /api/gift-processing/v1/recurring-gifts
PATCH /api/gift-processing/v1/recurring-gifts/{recurringGiftId}
# Motivation administration
GET /api/gift-processing/v1/motivations
POST /api/gift-processing/v1/motivations
PATCH /api/gift-processing/v1/motivations/{groupCode}/{detailCode}
# Read path consumed by Receipt Generation Service
GET /api/gift-processing/v1/donors/{partnerKey}/posted-gifts?year=&ledgerId=
B.7.1.2 Sample C# Endpoint (Minimal API)
// Program.cs — Gift Processing API endpoint group
var batches = app.MapGroup("/api/gift-processing/v1/batches")
.RequireAuthorization()
.WithOpenApi();
batches.MapPost("/{batchId:int}/post", async (
int batchId,
IGiftBatchPostingService posting,
ClaimsPrincipal user,
CancellationToken ct) =>
{
var result = await posting.PostBatchAsync(batchId, user.GetUserId(), ct);
return result switch
{
PostingResult.Success s => Results.Ok(s.PostedBatch),
PostingResult.AlreadyPosted => Results.Conflict(new ProblemDetails {
Title = "Batch already posted",
Status = StatusCodes.Status409Conflict
}),
PostingResult.PeriodClosed pc => Results.Problem(
title: "Posting period closed",
detail: $"Period {pc.Year}/{pc.Period} is closed for posting.",
statusCode: StatusCodes.Status422UnprocessableEntity),
PostingResult.PartnerInvalid p => Results.Problem(
title: "Partner validation failed",
detail: string.Join("; ", p.InvalidDonorKeys),
statusCode: StatusCodes.Status422UnprocessableEntity),
_ => Results.Problem("Unknown posting result")
};
})
.WithName("PostGiftBatch")
.Produces<GiftBatchDto>(200)
.Produces<ProblemDetails>(409)
.Produces<ProblemDetails>(422);
B.7.1.3 OpenAPI Excerpt — POST a Gift Batch
paths:
/api/gift-processing/v1/batches:
post:
summary: Create a new (unposted) gift batch
operationId: createGiftBatch
tags: [GiftBatches]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/CreateGiftBatchRequest'
responses:
'201':
description: Created
content:
application/json:
schema:
$ref: '#/components/schemas/GiftBatchDto'
'422':
description: Validation failure
content:
application/problem+json:
schema:
$ref: '#/components/schemas/ProblemDetails'
components:
schemas:
CreateGiftBatchRequest:
type: object
required: [ledgerId, batchDescription, glEffectiveDate, currencyCode]
properties:
ledgerId: { type: integer, example: 1 }
batchDescription: { type: string, maxLength: 80 }
glEffectiveDate: { type: string, format: date }
currencyCode: { type: string, pattern: '^[A-Z]{3}$' }
exchangeRateToBase: { type: number, format: double }
B.7.2 Receipt Generation Service
| Attribute | Value |
|---|---|
| Purpose | Generate annual tax-deductible receipt PDFs from posted gifts. Owns the HTML template registry, PDF rendering pipeline, and the receipt issuance audit trail. |
| Organizational owner | Compliance / Tax Reporting team. |
| Business capabilities | On-demand single-donor receipt generation; bulk annual receipt run for an entire ledger/year; HTML template management per tax jurisdiction; donor statement export (CSV/PDF); receipt sequence number assignment; reprint with audit trail. |
| Owned tables | receipt_issuance, receipt_template (2 tables, added by this service). |
| Upstream | Angular 20 SPA (Annual Receipts screen); optional cron trigger for year-end bulk runs. |
| Downstream | Gift Processing API (GET posted-gifts via REST); Azure Blob Storage (PDF write, immutable retention policy). |
| Azure resources | Azure App Service P1v3 (autoscale 1–6, scales to 6 only during Jan–Feb year-end window); Azure Database for PostgreSQL (small footprint, shares server with Gift Processing); Azure Blob Storage (PDF artifacts); Azure Service Bus (provisioned on demand, not by default). |
| Auth (user) | Microsoft Entra ID JWT Bearer. |
| Auth (service-to-service outbound) | Managed Identity to Gift Processing API. |
| Scaling profile | Episodic: scales 1→6 during year-end window; idles at 1 instance the rest of the year. Autoscale rule: queue depth > 50 OR CPU > 70% → scale out. |
B.7.2.1 REST Contract
# Single-donor receipt (synchronous)
POST /api/receipts/v1/receipts
Body: { "donorPartnerKey": 12345, "taxYear": 2025, "ledgerId": 1, "jurisdictionCode": "DE" }
201: { "receiptId": "...", "sequenceNumber": "DE-2025-0042178",
"blobUrl": "...", "issuedAt": "..." }
# Bulk annual run (asynchronous)
POST /api/receipts/v1/runs
Body: { "ledgerId": 1, "taxYear": 2025, "jurisdictionCode": "DE" }
202: { "runId": "...", "statusUrl": "/api/receipts/v1/runs/{runId}" }
GET /api/receipts/v1/runs/{runId}
200: { "runId": "...", "status": "InProgress|Completed|Failed",
"totalDonors": 4821, "completed": 1840, "failed": 0,
"startedAt": "...", "completedAt": null }
# Receipt retrieval
GET /api/receipts/v1/receipts/{receiptId}
200: { "receiptId": "...", "donorPartnerKey": 12345,
"taxYear": 2025, "sequenceNumber": "DE-2025-0042178",
"blobUrl": "", "issuedAt": "...",
"reissuedFrom": null }
# Reprint with audit trail
POST /api/receipts/v1/receipts/{receiptId}:reissue
Body: { "reason": "Donor address corrected" }
201: { "receiptId": "", "sequenceNumber": "DE-2025-0042178-R1",
"blobUrl": "...", "reissuedFrom": "" }
# Template registry
GET /api/receipts/v1/templates?jurisdiction=&language=
PUT /api/receipts/v1/templates/{templateId}
B.7.2.2 C# Interface for the Posted-Gifts Read Path
// Receipt Generation Service — outbound client to Gift Processing API
public interface IGiftProcessingClient
{
Task<DonorPostedGifts> GetPostedGiftsAsync(
long partnerKey,
int taxYear,
int ledgerId,
CancellationToken ct);
}
public sealed class GiftProcessingClient : IGiftProcessingClient
{
private readonly HttpClient _http; // Polly-decorated via AddHttpClient
private readonly ILogger<GiftProcessingClient> _log;
public GiftProcessingClient(HttpClient http, ILogger<GiftProcessingClient> log)
{
_http = http;
_log = log;
}
public async Task<DonorPostedGifts> GetPostedGiftsAsync(
long partnerKey, int taxYear, int ledgerId, CancellationToken ct)
{
var url = $"/api/gift-processing/v1/donors/{partnerKey}/posted-gifts" +
$"?year={taxYear}&ledgerId={ledgerId}";
var response = await _http.GetAsync(url, ct);
response.EnsureSuccessStatusCode();
var payload = await response.Content
.ReadFromJsonAsync<DonorPostedGifts>(cancellationToken: ct);
return payload
?? throw new InvalidOperationException("Empty posted-gifts payload");
}
}
// DI registration with Polly resilience pipeline
builder.Services.AddHttpClient<IGiftProcessingClient, GiftProcessingClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["GiftProcessing:BaseUrl"]!);
client.DefaultRequestHeaders.Add("Accept", "application/json");
})
.AddPolicyHandler(GiftProcessingPolicies.RetryWithExponentialBackoff())
.AddPolicyHandler(GiftProcessingPolicies.CircuitBreaker())
.AddHttpMessageHandler<ManagedIdentityAuthHandler>();
B.7.2.3 Optional Async Envelope (Azure Service Bus, when provisioned)
# Azure Service Bus topic: receipts-bulk-run
# Subscription: receipt-generation-service
# Used only when bulk-run mode is > ~30 min synchronous or durable retry is required.
{
"specversion": "1.0",
"type": "petra.receipts.donor-receipt-requested.v1",
"source": "/receipts/runs/r-2025-de-001",
"id": "msg-7c8b1a3e",
"time": "2026-01-15T03:42:11Z",
"subject": "donor/12345",
"datacontenttype": "application/json",
"data": {
"runId": "r-2025-de-001",
"donorPartnerKey": 12345,
"taxYear": 2025,
"ledgerId": 1,
"jurisdictionCode": "DE",
"languageCode": "de-DE"
},
"traceparent": "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
}
B.8 Target Integration Architecture
graph LR
SPA["Angular 20 SPA
(5 screens)"]
FD["Azure Front Door
(WAF + TLS)"]
APIM["Azure API Management
(strangler-fig routing)"]
subgraph "Gift Processing (year-round, autoscale 2-4)"
GIFT["Gift Processing API
(.NET 10 Minimal API)"]
end
subgraph "Receipt Generation (episodic, autoscale 1-6)"
RCPT["Receipt Generation Service
(.NET 10)"]
end
PG[("Azure Database
for PostgreSQL")]
REDIS["Azure Cache
for Redis"]
BLOB["Azure Blob Storage
(bank files +
receipt PDFs)"]
SB["Azure Service Bus
(optional, on-demand)"]
LEGACY_PARTNER["Legacy Partner Service
(read-only)"]
LEGACY_GL["Legacy GL Service
(one-way posting)"]
SPA --> FD
FD --> APIM
APIM -->|"/api/gift-processing/*"| GIFT
APIM -->|"/api/receipts/*"| RCPT
GIFT --> PG
GIFT --> REDIS
GIFT --> BLOB
GIFT -.->|"REST + Polly"| LEGACY_PARTNER
GIFT -.->|"REST + Polly"| LEGACY_GL
RCPT --> PG
RCPT --> BLOB
RCPT -->|"REST + Managed Identity
GET posted-gifts"| GIFT
RCPT -.->|"optional, on-demand"| SB
SB -.->|"optional"| RCPT
classDef modern fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
classDef azure fill:#e3f2fd,stroke:#1565c0,stroke-width:2px
classDef legacy fill:#fff3e0,stroke:#e65100,stroke-width:1px,stroke-dasharray:5
classDef client fill:#f3e5f5,stroke:#6a1b9a,stroke-width:2px
class SPA client
class GIFT,RCPT modern
class FD,APIM,PG,REDIS,BLOB,SB azure
class LEGACY_PARTNER,LEGACY_GL legacy
Receipt Generation Service depends on Gift Processing API only via REST. Gift Processing API has zero awareness of receipt issuance — this is a strictly one-way dependency. Both services sit behind the same Azure API Management instance, share the same authentication surface (Microsoft Entra ID), and emit telemetry to the same Application Insights workspace. The optional Azure Service Bus path is dashed because it is provisioned only when bulk-run volume justifies it.
B.9 Implementation Roadmap
| Iteration | Weeks | Deliverable | Service |
|---|---|---|---|
| 1 | 1–2 | Repository skeleton: two .NET 10 solutions, Bicep templates for App Service + PostgreSQL + Blob + Key Vault + Redis, GitHub Actions baseline (build → test → deploy-staging → smoke → swap) | Shared |
| 2 | 3–5 | gift_batch + gift + gift_detail CRUD, posting state machine, EF Core migrations, FluentValidation rules | Gift Processing API |
| 3 | 6–7 | Motivation administration + recurring gifts + SEPA mandate handling | Gift Processing API |
| 4 | 8–9 | Bank import: CAMT v053 / MT940 / CSV / ZIP parser ports + Blob upload pipeline | Gift Processing API |
| 5 | 10–11 | APIM strangler bridge wiring: Legacy Partner read-only validation + Legacy GL one-way posting (Polly retry + circuit breaker) | Gift Processing API |
| 6 | 12–13 | Template registry + single-donor receipt endpoint + HTML rendering pipeline (Razor / PuppeteerSharp container) | Receipt Generation Service |
| 7 | 14–15 | Bulk annual receipt run with synchronous progress reporting (Service Bus deferred unless needed) | Receipt Generation Service |
| 8 | 16 | End-to-end Playwright + integration tests; slot-swap rehearsal; observability dashboards; runbook | Shared |
B.10 Quality Gate Result
Three lenses; three independent scores; all three above the 7.0 quality gate; convergent answers to all five open questions raised by the target-architecture phase. Composite score 7.8 / 10. Quality gate: PASS.
Appendix C: Deployment and Infrastructure
C.1 Deployment Overview
The runtime topology for Petra Finance — Gift Processing modernization is intentionally lean: two App Service plans, one Azure Database for PostgreSQL Flexible Server (shared across both services with separate schemas), one Azure Service Bus namespace (provisioned on demand), one Azure API Management instance (the strangler-fig surface), one Azure Cache for Redis, two Azure Blob Storage containers (SEPA exports + receipt PDFs), and one Application Insights workspace. Everything is provisioned through Bicep modules; nothing is created by hand.
The two App Services map 1:1 to the services defined in Appendix B: Gift Processing API hosts the .NET 10 Minimal API for batch entry, posting, recurring gifts, bank import, and motivation administration; Receipt Generation Service hosts the .NET 10 Minimal API plus QuestPDF rendering for annual tax receipts. Both services share the PostgreSQL server but own separate schemas (gift_processing, receipts) and connect via separate Managed Identities — the receipt service has no gift_processing schema permissions and reaches gift data only over REST.
Topology Diagram
flowchart TD
SPA["Angular 20 SPA
(static in App Service)"]
AFD["Azure Front Door
+ WAF policies"]
APIM["Azure APIM
(strangler-fig surface)"]
GIFT["Gift Processing API
App Service P1v3
slots: staging / prod"]
RECEIPT["Receipt Generation API
App Service P1v3
autoscale 1-6 (Jan-Feb)"]
LEGACY["Legacy Petra
.asmx (Mono)"]
PG[("Azure PostgreSQL
Flexible Server
gift_proc + receipts schemas")]
SB["Azure Service Bus
(on-demand, optional)"]
BLOB[("Blob Storage
receipts + SEPA exports")]
AI["Application Insights
+ Log Analytics workspace"]
SPA --> AFD --> APIM
APIM -->|"per-endpoint traffic shift"| GIFT
APIM -->|"strangler routing"| LEGACY
GIFT -->|"HTTP (Managed Identity)"| LEGACY
GIFT --> PG
GIFT -->|"HTTP (MI)"| RECEIPT
RECEIPT --> BLOB
RECEIPT --> SB
SPA -. OTLP .-> AI
GIFT -. OTLP .-> AI
RECEIPT -. OTLP .-> AI
C.2 Infrastructure as Code (Bicep)
Infrastructure is declared in modular Bicep at infra/main.bicep with per-resource modules under infra/modules/. Below is the orchestrating main.bicep followed by the App Service module for Gift Processing API; the equivalent Receipt Generation module differs only in the resource names, autoscale window, and an extra Blob role assignment.
C.2.1 main.bicep
// infra/main.bicep
targetScope = 'subscription'
@description('Environment short name: dev, stg, prd')
param environment string = 'stg'
@description('Azure region')
param location string = 'westeurope'
@description('Project tag applied to every resource')
param project string = 'petra-gift'
var resourceGroupName = 'rg-${project}-${environment}'
var tags = {
project: project
environment: environment
owner: 'finance-modernization'
costCenter: 'cc-modernization-2026'
}
resource rg 'Microsoft.Resources/resourceGroups@2024-03-01' = {
name: resourceGroupName
location: location
tags: tags
}
module logAnalytics './modules/log-analytics.bicep' = {
name: 'log-analytics'
scope: rg
params: {
workspaceName: 'log-${project}-${environment}'
location: location
tags: tags
retentionDays: 90
}
}
module appInsights './modules/app-insights.bicep' = {
name: 'app-insights'
scope: rg
params: {
name: 'appi-${project}-${environment}'
location: location
tags: tags
workspaceResourceId: logAnalytics.outputs.workspaceId
samplingPercentage: 100
}
}
module keyVault './modules/key-vault.bicep' = {
name: 'key-vault'
scope: rg
params: {
name: 'kv-${project}-${environment}'
location: location
tags: tags
enableRbac: true
purgeProtection: true
softDeleteDays: 90
}
}
module postgres './modules/postgres-flexible.bicep' = {
name: 'postgres-flexible'
scope: rg
params: {
name: 'psql-${project}-${environment}'
location: location
tags: tags
sku: 'Standard_D2ds_v5'
storageGb: 32
backupRetentionDays: 7
aadAdminLogin: 'finance-mod-admins'
aadAdminObjectId: aadAdminGroupObjectId
}
}
module serviceBus './modules/service-bus.bicep' = if (provisionServiceBus) {
name: 'service-bus'
scope: rg
params: {
namespaceName: 'sb-${project}-${environment}'
location: location
tags: tags
sku: 'Standard'
topicName: 'petra-receipts'
}
}
module redis './modules/redis-cache.bicep' = {
name: 'redis-cache'
scope: rg
params: {
name: 'redis-${project}-${environment}'
location: location
tags: tags
sku: 'Standard'
family: 'C'
capacity: 1
}
}
module storage './modules/storage-account.bicep' = {
name: 'storage'
scope: rg
params: {
name: 'st${replace(project, '-', '')}${environment}'
location: location
tags: tags
containers: [
'receipts'
'sepa-exports'
'bank-statements'
]
immutableRetentionDays: 2555 // 7-yr tax receipt retention
}
}
module appSvcPlan './modules/app-service-plan.bicep' = {
name: 'app-svc-plan'
scope: rg
params: {
name: 'asp-${project}-${environment}'
location: location
tags: tags
sku: 'P1v3'
capacity: 2
}
}
module giftApi './modules/app-service-dotnet.bicep' = {
name: 'gift-processing-api'
scope: rg
params: {
appServiceName: 'app-gift-${environment}'
planResourceId: appSvcPlan.outputs.planId
location: location
tags: tags
keyVaultName: keyVault.outputs.name
appInsightsKey: appInsights.outputs.connectionString
pgServerFqdn: postgres.outputs.fqdn
pgSchema: 'gift_processing'
redisHost: redis.outputs.host
storageAccount: storage.outputs.name
storageContainers: [ 'sepa-exports', 'bank-statements' ]
autoscaleMin: 2
autoscaleMax: 4
healthCheckPath: '/health/ready'
}
}
module receiptsApi './modules/app-service-dotnet.bicep' = {
name: 'receipts-api'
scope: rg
params: {
appServiceName: 'app-receipts-${environment}'
planResourceId: appSvcPlan.outputs.planId
location: location
tags: tags
keyVaultName: keyVault.outputs.name
appInsightsKey: appInsights.outputs.connectionString
pgServerFqdn: postgres.outputs.fqdn
pgSchema: 'receipts'
redisHost: redis.outputs.host
storageAccount: storage.outputs.name
storageContainers: [ 'receipts' ]
autoscaleMin: 1
autoscaleMax: 6
healthCheckPath: '/health/ready'
extraAppSettings: {
'GiftProcessing__BaseUrl':
'https://app-gift-${environment}.azurewebsites.net'
'GiftProcessing__Scope':
'api://gift-${environment}/.default'
}
}
}
module apim './modules/api-management.bicep' = {
name: 'apim'
scope: rg
params: {
name: 'apim-${project}-${environment}'
location: location
tags: tags
publisherEmail: 'modernization@example.org'
publisherName: 'Finance Modernization'
sku: 'Developer'
legacyBackendUrl: 'https://legacy-petra.example.org'
modernBackendUrl: giftApi.outputs.defaultHostname
stranglerRoutingKey: 'posting-status'
}
}
param aadAdminGroupObjectId string
param provisionServiceBus bool = false
C.2.2 app-service-dotnet.bicep (the reusable App Service module)
// infra/modules/app-service-dotnet.bicep
param appServiceName string
param planResourceId string
param location string
param tags object
param keyVaultName string
param appInsightsKey string
param pgServerFqdn string
param pgSchema string
param redisHost string
param storageAccount string
param storageContainers array
param autoscaleMin int
param autoscaleMax int
param healthCheckPath string
param extraAppSettings object = {}
var baseAppSettings = {
'ASPNETCORE_ENVIRONMENT': 'Production'
'ASPNETCORE_FORWARDEDHEADERS_ENABLED': 'true'
// OpenTelemetry
'OTEL_SERVICE_NAME': appServiceName
'OTEL_EXPORTER_OTLP_ENDPOINT':
'https://${location}.applicationinsights.azure.com/v2/track'
'APPLICATIONINSIGHTS_CONNECTION_STRING': appInsightsKey
'OTEL_RESOURCE_ATTRIBUTES':
'deployment.environment=${tags.environment}'
'LOG_LEVEL': 'Information'
// PostgreSQL (Managed Identity, no password)
'ConnectionStrings__GiftDb':
'Host=${pgServerFqdn};Database=petra;Username=${appServiceName};SslMode=Require;Trust Server Certificate=true'
'Postgres__Schema': pgSchema
'Postgres__UseManagedIdentity': 'true'
// Redis
'Redis__Host': redisHost
'Redis__UseManagedIdentity': 'true'
// Storage
'Storage__AccountName': storageAccount
'Storage__Containers': join(storageContainers, ',')
// Key Vault references (resolved at runtime by App Service)
'Entra__TenantId':
'@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=EntraTenantId)'
'Entra__ClientId':
'@Microsoft.KeyVault(VaultName=${keyVaultName};SecretName=${appServiceName}-ClientId)'
}
resource app 'Microsoft.Web/sites@2024-04-01' = {
name: appServiceName
location: location
tags: tags
kind: 'app,linux'
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: planResourceId
httpsOnly: true
clientAffinityEnabled: false
publicNetworkAccess: 'Enabled'
keyVaultReferenceIdentity: 'SystemAssigned'
siteConfig: {
linuxFxVersion: 'DOTNETCORE|8.0'
alwaysOn: true
http20Enabled: true
ftpsState: 'Disabled'
minTlsVersion: '1.2'
healthCheckPath: healthCheckPath
appSettings: [for setting in items(union(baseAppSettings, extraAppSettings)): {
name: setting.key
value: setting.value
}]
}
}
}
resource stagingSlot 'Microsoft.Web/sites/slots@2024-04-01' = {
parent: app
name: 'staging'
location: location
tags: tags
identity: {
type: 'SystemAssigned'
}
properties: {
serverFarmId: planResourceId
httpsOnly: true
siteConfig: {
linuxFxVersion: 'DOTNETCORE|8.0'
alwaysOn: true
healthCheckPath: healthCheckPath
appSettings: [for setting in items(union(baseAppSettings, extraAppSettings)): {
name: setting.key
value: setting.value
}]
}
}
}
resource autoscale 'Microsoft.Insights/autoscalesettings@2022-10-01' = {
name: 'autoscale-${appServiceName}'
location: location
tags: tags
properties: {
enabled: true
targetResourceUri: planResourceId
profiles: [
{
name: 'default'
capacity: {
minimum: string(autoscaleMin)
maximum: string(autoscaleMax)
default: string(autoscaleMin)
}
rules: [
{
metricTrigger: {
metricName: 'CpuPercentage'
metricResourceUri: planResourceId
timeGrain: 'PT1M'
statistic: 'Average'
timeWindow: 'PT5M'
timeAggregation: 'Average'
operator: 'GreaterThan'
threshold: 70
}
scaleAction: {
direction: 'Increase'
type: 'ChangeCount'
value: '1'
cooldown: 'PT5M'
}
}
{
metricTrigger: {
metricName: 'CpuPercentage'
metricResourceUri: planResourceId
timeGrain: 'PT1M'
statistic: 'Average'
timeWindow: 'PT10M'
timeAggregation: 'Average'
operator: 'LessThan'
threshold: 25
}
scaleAction: {
direction: 'Decrease'
type: 'ChangeCount'
value: '1'
cooldown: 'PT10M'
}
}
]
}
]
}
}
output principalId string = app.identity.principalId
output defaultHostname string = app.properties.defaultHostName
C.2.3 Key Vault references & Managed-Identity role assignments
// infra/modules/role-assignments.bicep
param keyVaultName string
param postgresServerName string
param storageAccountName string
param redisName string
param appPrincipalId string
// 1. Key Vault Secrets User
resource kvSecretsUser 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(keyVaultName, appPrincipalId, 'kv-secrets-user')
scope: resourceId('Microsoft.KeyVault/vaults', keyVaultName)
properties: {
principalId: appPrincipalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'4633458b-17de-408a-b874-0445c86b69e6') // Key Vault Secrets User
}
}
// 2. PostgreSQL: AAD-authenticated app login
// (the actual SQL GRANT statements run as a post-deploy step via az cli)
// 3. Storage Blob Data Contributor
resource storageContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(storageAccountName, appPrincipalId, 'storage-blob-contrib')
scope: resourceId('Microsoft.Storage/storageAccounts', storageAccountName)
properties: {
principalId: appPrincipalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'ba92f5b4-2d11-453d-a403-e96b0029c9fe') // Storage Blob Data Contributor
}
}
// 4. Redis Data Contributor
resource redisContributor 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
name: guid(redisName, appPrincipalId, 'redis-data-contrib')
scope: resourceId('Microsoft.Cache/redis', redisName)
properties: {
principalId: appPrincipalId
principalType: 'ServicePrincipal'
roleDefinitionId: subscriptionResourceId(
'Microsoft.Authorization/roleDefinitions',
'e0f68234-74aa-48ed-b826-c38b57376e17') // Redis Cache Contributor
}
}
C.3 CI/CD Pipeline (GitHub Actions)
The pipeline runs on every push to main and on PRs. Deploy steps use Azure OIDC federated credentials (no service-principal secrets); the production swap is gated by a deployment-environment approval.
# .github/workflows/deploy-gift-api.yml
name: Deploy Gift Processing API
on:
push:
branches: [ main ]
paths:
- 'src/Petra.GiftProcessing/**'
- 'infra/**'
- '.github/workflows/deploy-gift-api.yml'
workflow_dispatch:
permissions:
id-token: write # OIDC federated credential
contents: read
env:
AZURE_RG: rg-petra-gift-prd
APP_NAME: app-gift-prd
DOTNET_VERSION: '8.0.x'
BICEP_FILE: infra/main.bicep
ARTIFACT_NAME: petra-gift-api
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
- run: dotnet restore src/Petra.GiftProcessing.sln
- run: dotnet build src/Petra.GiftProcessing.sln -c Release --no-restore
- run: dotnet test src/Petra.GiftProcessing.sln -c Release --no-build
--logger trx --collect:"XPlat Code Coverage"
- run: dotnet publish src/Petra.GiftProcessing/Petra.GiftProcessing.csproj
-c Release -o ./publish --no-build
- uses: actions/upload-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ./publish
infra:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: What-if (preview)
run: |
az deployment sub what-if \
--location westeurope \
--template-file ${{ env.BICEP_FILE }} \
--parameters environment=prd \
aadAdminGroupObjectId=${{ secrets.AAD_ADMIN_GROUP_ID }}
- name: Deploy infra
run: |
az deployment sub create \
--location westeurope \
--template-file ${{ env.BICEP_FILE }} \
--parameters environment=prd \
aadAdminGroupObjectId=${{ secrets.AAD_ADMIN_GROUP_ID }}
deploy-staging:
needs: [ build, infra ]
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/download-artifact@v4
with:
name: ${{ env.ARTIFACT_NAME }}
path: ./publish
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Deploy to staging slot
uses: azure/webapps-deploy@v3
with:
app-name: ${{ env.APP_NAME }}
slot-name: staging
package: ./publish
smoke:
needs: deploy-staging
runs-on: ubuntu-latest
steps:
- name: Wait for health probe
run: |
for i in $(seq 1 30); do
code=$(curl -s -o /dev/null -w "%{http_code}" \
https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/ready)
if [ "$code" = "200" ]; then echo "ready"; exit 0; fi
sleep 10
done
echo "health probe failed"; exit 1
- name: API smoke
run: |
curl -fsS https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/ready
curl -fsS https://${{ env.APP_NAME }}-staging.azurewebsites.net/health/live
curl -fsS https://${{ env.APP_NAME }}-staging.azurewebsites.net/api/gift-processing/v1/ledgers \
-H "Authorization: Bearer ${{ secrets.SMOKE_TEST_TOKEN }}" \
| jq '.items | length >= 1'
swap-to-prod:
needs: smoke
runs-on: ubuntu-latest
environment: production
steps:
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-id: ${{ secrets.AZURE_TENANT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
- name: Slot swap
run: |
az webapp deployment slot swap \
--resource-group ${{ env.AZURE_RG }} \
--name ${{ env.APP_NAME }} \
--slot staging --target-slot production
C.4 App Service Deployment Manifests
C.4.1 web.config (Linux In-Process / Kestrel front-loaded)
Even on Linux App Service the .NET 10 runtime expects a minimal web.config when long-running connections or custom forwarded-headers handling is needed. Kestrel runs in-process; nginx in front of it terminates TLS.
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<location path="." inheritInChildApplications="false">
<system.webServer>
<handlers>
<add name="aspNetCore"
path="*" verb="*"
modules="AspNetCoreModuleV2"
resourceType="Unspecified" />
</handlers>
<aspNetCore processPath="dotnet"
arguments=".\Petra.GiftProcessing.dll"
stdoutLogEnabled="false"
stdoutLogFile=".\logs\stdout"
hostingModel="inprocess">
<environmentVariables>
<environmentVariable name="ASPNETCORE_ENVIRONMENT"
value="Production" />
<environmentVariable name="ASPNETCORE_FORWARDEDHEADERS_ENABLED"
value="true" />
</environmentVariables>
</aspNetCore>
</system.webServer>
</location>
</configuration>
C.4.2 Health probes (Minimal API)
// src/Petra.GiftProcessing/Program.cs (excerpt)
using Microsoft.Extensions.Diagnostics.HealthChecks;
builder.Services
.AddHealthChecks()
.AddNpgSql(
builder.Configuration.GetConnectionString("GiftDb")!,
name: "postgres",
failureStatus: HealthStatus.Unhealthy,
tags: new[] { "ready" })
.AddRedis(
builder.Configuration["Redis:Host"]!,
name: "redis",
failureStatus: HealthStatus.Degraded,
tags: new[] { "ready" })
.AddAzureBlobStorage(
builder.Configuration["Storage:AccountName"]!,
name: "blob",
tags: new[] { "ready" })
.AddCheck("self", () => HealthCheckResult.Healthy(),
tags: new[] { "live" });
var app = builder.Build();
app.MapHealthChecks("/health/live", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("live")
});
app.MapHealthChecks("/health/ready", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready")
});
app.MapHealthChecks("/health/startup", new HealthCheckOptions
{
Predicate = c => c.Tags.Contains("ready"),
ResultStatusCodes = new Dictionary<HealthStatus, int>
{
[HealthStatus.Healthy] = 200,
[HealthStatus.Degraded] = 200,
[HealthStatus.Unhealthy] = 503
}
});
C.5 Networking and Routing
Three layers in front of the App Services:
- Azure Front Door — global TLS termination + WAF (OWASP Core Rule Set v3.2 + custom rule blocking known SOAP-injection patterns aimed at the legacy ASMX surface).
- Azure API Management (Developer/Standard tier, depending on environment) — the strangler-fig surface. Per-endpoint routing policies live here:
POST /api/gift-processing/v1/batches/{id}/postis the first endpoint shifted from legacy to modern; reads precede writes per Section 11. APIM policies inject the Managed Identity token into the upstream call and propagate the W3Ctraceparentheader. - Private endpoints — PostgreSQL Flexible Server, Redis, Storage, Key Vault, and Service Bus all expose private endpoints into the App Service VNet integration subnet. No public network access on any data plane.
APIM policy fragment (strangler routing)
<policies>
<inbound>
<base />
<!-- Strangler routing: send POST /batches/{id}/post
to the modernized API; all other writes stay
on legacy until their stage is reached -->
<choose>
<when condition="@(context.Request.Method == "POST" &&
context.Request.Url.Path.Contains("/post"))">
<set-backend-service base-url="https://app-gift-prd.azurewebsites.net" />
<authentication-managed-identity resource="api://gift-prd" />
</when>
<otherwise>
<set-backend-service base-url="https://legacy-petra.example.org" />
</otherwise>
</choose>
<forward-request />
</inbound>
<outbound>
<base />
<set-header name="x-strangler-stage" exists-action="override">
<value>posting-modernized</value>
</set-header>
</outbound>
</policies>
C.6 Observability
Observability runs on three concentric layers: in-process OpenTelemetry SDK, the App Service / platform metrics, and Application Insights as the unified backend. Logs, traces, and metrics all land in Application Insights via OTLP; Application Insights writes long-term storage to the shared Log Analytics workspace.
C.6.1 OpenTelemetry registration (Program.cs)
// src/Petra.GiftProcessing/Program.cs
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
using OpenTelemetry.Metrics;
using OpenTelemetry.Logs;
using Azure.Monitor.OpenTelemetry.AspNetCore;
var otelResource = ResourceBuilder.CreateDefault()
.AddService(
serviceName: builder.Configuration["OTEL_SERVICE_NAME"]!,
serviceVersion: ThisAssembly.AssemblyInformationalVersion)
.AddAttributes(new Dictionary<string, object>
{
["deployment.environment"] =
builder.Configuration["ASPNETCORE_ENVIRONMENT"]!
});
builder.Services
.AddOpenTelemetry()
.UseAzureMonitor(o =>
{
o.ConnectionString =
builder.Configuration[
"APPLICATIONINSIGHTS_CONNECTION_STRING"];
o.SamplingRatio = 1.0f;
})
.ConfigureResource(r => r.AddService(
builder.Configuration["OTEL_SERVICE_NAME"]!))
.WithTracing(t => t
.AddSource("Petra.*")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddNpgsql())
.WithMetrics(m => m
.AddMeter("Petra.*")
.AddAspNetCoreInstrumentation()
.AddHttpClientInstrumentation()
.AddRuntimeInstrumentation());
// Serilog -> structured JSON -> stdout -> App Insights
builder.Host.UseSerilog((ctx, lc) => lc
.ReadFrom.Configuration(ctx.Configuration)
.Enrich.FromLogContext()
.Enrich.WithProperty("service",
ctx.Configuration["OTEL_SERVICE_NAME"]!)
.Enrich.WithCorrelationIdHeader("x-correlation-id")
.WriteTo.Console(
new Serilog.Formatting.Compact.RenderedCompactJsonFormatter()));
public static class Tracing
{
public static readonly ActivitySource Source =
new("Petra.GiftProcessing");
}
C.6.2 Application Insights queries (KQL)
// KQL — failed gift-batch posts last 24 hours, grouped by failure type
requests
| where timestamp > ago(24h)
| where name == "PostGiftBatch"
| where success == false
| extend problemType = tostring(customDimensions.["traceparent"])
| summarize count() by resultCode, problemType
| order by count_ desc
// KQL — receipt-generation throughput during year-end (Jan + Feb)
customEvents
| where name == "receipts.run.completed"
| extend year = toint(customDimensions["taxYear"])
| extend donorCount = toint(customDimensions["donorCount"])
| extend durationMs = toint(customDimensions["durationMs"])
| project timestamp, year, donorCount, durationMs
| order by timestamp desc
// KQL — strangler-bridge end-to-end latency (P50/P95/P99)
dependencies
| where timestamp > ago(1h)
| where target == "legacy-petra.example.org"
| summarize
p50 = percentile(duration, 50),
p95 = percentile(duration, 95),
p99 = percentile(duration, 99)
by bin(timestamp, 5m)
C.6.3 OTEL Environment Variables (reference)
| Environment Variable | Value | Purpose |
|---|---|---|
OTEL_SERVICE_NAME | app-gift-prd / app-receipts-prd | Service identity for traces and metrics |
OTEL_EXPORTER_OTLP_ENDPOINT | https://westeurope.applicationinsights.azure.com/v2/track | OTLP endpoint for traces, metrics, logs |
APPLICATIONINSIGHTS_CONNECTION_STRING | Key Vault reference | Azure Monitor auth + workspace routing |
OTEL_RESOURCE_ATTRIBUTES | deployment.environment=prd | Per-environment resource tagging |
ENVIRONMENT | prd / stg / dev | App-level environment switch |
LOG_LEVEL | Information (prd), Debug (dev) | Serilog minimum level |
C.7 Secrets & Configuration (Key Vault references)
No secrets in appsettings.json, no secrets in environment variables, no secrets in pipeline output. Every secret is a Key Vault reference resolved by App Service at startup; rotation happens in Key Vault without redeploying the app.
| Secret | Key Vault Reference | Rotation Cadence |
|---|---|---|
| Entra ID Tenant ID | @Microsoft.KeyVault(VaultName=kv-petra-gift-prd;SecretName=EntraTenantId) | Effectively static |
| Entra ID Client ID (per service) | @Microsoft.KeyVault(...;SecretName=app-gift-prd-ClientId) | Annual |
| SendGrid API key (Receipt service) | @Microsoft.KeyVault(...;SecretName=SendGridApiKey) | 90 days |
| Legacy Partner service shared secret (bridge) | @Microsoft.KeyVault(...;SecretName=LegacyPartnerSecret) | 30 days (during cutover); decommissioned after Phase 6 |
| Application Insights connection string | App-Service setting (system-managed) | Effectively static |
All database connections (PostgreSQL, Redis) use Managed Identity — no passwords stored anywhere. The Bicep role-assignments module wires the System-Assigned Managed Identity of each App Service to the data services it needs.
C.8 Cost Estimates
| Resource | SKU / Size | Estimated Monthly (EUR) | Notes |
|---|---|---|---|
| App Service Plan (P1v3, 2 instances) | P1v3 (2 vCPU, 8 GiB) | ~€230 | Shared between both App Services |
| Azure Database for PostgreSQL Flexible Server | Standard_D2ds_v5, 32 GB | ~€200 | Both schemas; ZRS backup, 7-day retention |
| Azure Cache for Redis | Standard C1 (1 GB) | ~€55 | Reference-data + receipt-template cache |
| Azure Blob Storage | Hot, ZRS, ~50 GB | ~€10 | Includes immutable retention on receipts |
| API Management | Developer tier (dev/stg) / Standard (prd) | €45 / €500 | Standard required for prod SLA; Developer fine for staging |
| Application Insights + Log Analytics | ~5 GB / day ingest, 90-day retention | ~€130 | 100% sampling enabled given low volume |
| Azure Service Bus | Standard (on-demand) | €10 / €0 when un-provisioned | Currently optional — only Receipt Generation bulk path uses it |
| Front Door + WAF | Standard | ~€30 | WAF rules included |
| Key Vault | Standard, ~10K secret ops/month | ~€3 | Negligible |
| Total (Production) | ~€1,170 / month | ||
| Total (Staging) | ~€490 / month | Smaller SKUs throughout | |
Costs are illustrative for a single mid-size deployment in westeurope. Multi-region or sovereign-cloud deployments scale the App Service Plan and APIM lines proportionally; PostgreSQL replicas double the database cost. The bulk year-end receipt run scales the receipts App Service from 1 to 6 instances for ~6 weeks — ~€70 incremental.
C.9 Operational Runbook
| Scenario | Detection | First Response | Escalation |
|---|---|---|---|
| Gift batch posting failures spike | KQL query (§C.6.2 #1) > 5 failures / hr | Check problem-type breakdown; if period-out-of-range dominates → user-data issue (no action); if legacy-gl-down dominates → check legacy GL health page |
Pager to legacy-platform owner if legacy GL is down > 15 min |
| Receipt run stalled | Custom event receipts.run.heartbeat not seen for > 10 min |
Check Application Insights live metrics; verify Blob writes proceeding; check QuestPDF memory pressure | Restart the Receipt App Service instance handling the run; resume from last donor checkpoint |
| Strangler-bridge latency SLO breach | KQL P95 > 1.5s for > 5 min | Check legacy GL latency; if elevated, fail open APIM policy back to legacy temporarily | Page legacy-platform owner |
| CDC consumer lag > 30s | App Insights custom metric cdc.consumer.lag.seconds |
Check Service Bus queue depth; if growing, scale Gift Processing API up by 1; if shrinking, watch | If lag > 5 min, pause new Phase-4 stage advancement |
| Slot-swap failed smoke test | CI pipeline smoke step exits non-zero |
Inspect Application Insights for staging slot; do NOT swap | Investigate; rollback by redeploying the previous commit to staging slot |
Run-books live as Markdown in ops/runbooks/ alongside the code; each runbook ends with a "verify resolved" KQL snippet that confirms the metric returned to baseline.