1. Introduction
1.1 About This Document
This report presents a modernization analysis of Petra (OpenPetra), an open-source ERP platform serving non-profit organizations. The analysis was produced by Sage AI Technologies' modernization-planning workflow, which combines pre-computed Sage codebase intelligence with multi-agent reasoning to identify migration candidates, characterize the legacy system, and propose a target architecture on a modern .NET stack.
1.2 Candidate Selection Process
The workflow follows a multi-phase pipeline: (1) project intelligence (this section, plus Section 3), (2) candidate assembly driven by three independent subsystem-selection runs whose outputs are reconciled into a consensus pick (see Appendix A), (3) target architecture design, (4) behavioral rules extraction, and (5) technical examples for code, UI, and data mapping. For Petra, the candidate-selection problem space spans 27 documented business-function subsystems and 34 bounded contexts.
1.3 Report Contents
- Section 1 — Introduction (this section)
- Section 2 — Migration Scope (selected candidate subsystem)
- Section 3 — Legacy System Analysis
- Section 4 — Target Architecture (Azure App Service, .NET 8, Angular 18)
- Section 5 — Platform Affinity Analysis
- Section 6 — How Sage Helped This Migration Planning
- Section 7 — UI/UX Transformation Examples (jQuery → Angular 18)
- Section 8 — Code Translation Examples (.NET Framework 4.7 + .asmx → .NET 8 + minimal API)
- Section 9 — Data Mapping Strategy
- Section 10 — Business Rules Analysis
- Section 11 — Migration Execution & Legacy/Modern Coexistence
- Appendix A — Multi-Agent Subsystem Selection
- Appendix B — Service Architecture
- Appendix C — Deployment
1.4 What This Report Does NOT Include
- Cost estimates or budget figures.
- Effort estimates or delivery timelines.
- Specific cloud-account / tenancy / network-topology deployment plans.
- Vendor or product procurement recommendations.
1.5 About Petra (OpenPetra)
OpenPetra is a multi-tenant, multi-currency, multi-country ERP system for non-profit organizations. It manages contacts (donors, sponsors, volunteers, organizations, churches, banks, venues), donation processing with tax-deductible receipting, general-ledger accounting with year-end close, conference and event management (registration, accommodation, transportation), personnel and volunteer coordination, sponsorship programs (including sponsored-child management), and standardized + custom reporting. It is built as an n-tier client-server .NET application with a code-generation layer that transforms an XML master schema (petra.xml) into typed datasets, ORM classes, and RPC interfaces; the client is a JavaScript/jQuery web app that talks to an ASP.NET Web Services (.asmx, SOAP) layer hosted under Mono FastCGI on Linux, with a database-abstraction layer over PostgreSQL or MySQL.
Project Overview
| Project name | Petra (OpenPetra) |
|---|---|
| Total lines (cataloged) | 572,757 |
| Primary language | C# (.NET Framework 4.7) — 517 files / ~39% of catalog |
| Frontend | JavaScript + jQuery (56 files), HTML/CSS templates, plus generated form templates |
| Server framework | ASP.NET Web Services (.asmx, SOAP), Mono FastCGI hosting on Linux |
| Build system | NAnt (custom build), with XML/YAML-driven code generation |
| Datastores | PostgreSQL (primary), MySQL, SQLite (via abstraction layer) |
| Documented business-function subsystems | 27 |
| Documented technology subsystems | 32 |
| Aggregate roots (Sage) | 48 across 34 bounded contexts |
| Integration points (Sage) | 151 across 216 files |
| Architectural style | Layered / N-tier client-server with code-generated DAL |
| Target platform (this run) | Azure App Service; .NET 8 (server) + Angular 18 (client); PostgreSQL |
| Migration strategy | Classical strangler-fig (HTTP-routable, slice-by-slice) |
Source: Sage MCP get_project_metadata, get_project_business_function_subjects, get_project_technology_subjects, get_insight (domain_entity, integration). Project key petra.
2. Migration Scope
This report leverages Sage's deep insight into all 27 documented business-function subsystems in Petra (OpenPetra). A weighted scoring model was applied across each subsystem to find the right balance among migration risk, technical feasibility, and strategic value, with the goal of selecting a candidate slice for the first migration project — one that exercises the migration patterns the broader programme will rely on, demonstrates them on a meaningful business workflow, and stays within sensible blast-radius bounds for a pilot.
A single evaluation pass was conducted for this analysis; Sage’s three-pass consensus mode (which runs multiple independent scorings and reconciles them) is available for production engagements. The single-pass scoring is documented in Appendix A.
Recommended Migration Candidate: Finance — Gift Processing
Weighted Score: +2.85 / +5.10 (rank 1 of 25 scored subsystems; 2 subsystems unscored due to a Sage profile-coverage gap)
Risk: 5.5 / 10 (moderate) | Feasibility: 6.0 / 10 (good) | Strategic Value: 9.0 / 10 (high)
2.1 Why Finance — Gift Processing
Gift processing is OpenPetra's flagship donor workflow: a donor sends money, the back office reconciles a bank-statement import, posts a gift batch with motivations and tax-deductibility classification, and ultimately produces an annual tax receipt. Five distinct interactive web screens make up that flow:
- Gift Batches — entering and posting batches of donations with line-level allocation to motivations.
- Recurring Gift Batches — managing standing gifts and auto-generating periodic batches.
- Motivations Setup — the chart of motivation codes and motivation-detail-codes that drive how gifts are categorized and reported.
- Bank Import — importing bank statements (CAMT / MT940 / CSV) and matching transactions to expected gifts.
- Print Annual Receipts — generating tax-deductible receipts at year-end, per donor, with the right legal text per jurisdiction.
This is the right pilot for five reasons:
- End-to-end money-flow narrative. Bank import → gift batch → motivation classification → GL posting → annual receipt is a complete, recognizable donation processing workflow. It demos cleanly to non-profit prospects: a story about money and tax compliance, told screen-by-screen.
- Interactive UI surface that rewards modernization. Five distinct screens (versus one or two in most of the alternative slices) gives Section 7's UI/UX transformation analysis enough surface to demonstrate the AngularJS-to-Angular paradigm shift on real workflow forms — not on a single isolated maintenance dialog.
- Translation surface is large but tractable. Of the 279 files Sage associates with this subsystem, the hand-translated surface is roughly 23 server-side C# files (~15,000 lines), led by
Gift.Transactions.cs(6,963 lines),Gift.Importing.cs(1,998),Gift.Receipting.cs(1,794), andGift.Validation.cs(1,690). The remainder are XML report templates (45 files — template assets, ported through the template engine), SQL DDL / migration files (21 files — standard EF Core migration scope), and generated typed-dataset code that ports through the existing generation pipeline. - Shared infrastructure across screens. All five screens use the same web connector (
TGiftWebConnector), the same typed dataset (GiftBatchTDS), and the same RPC pattern (THttpConnector.CallWebConnector). Migrating five screens at once is roughly the cost of one connector port plus one typed-dataset port plus five Angular components — not five separate slices' worth of work. - Compliance posture without live regulator dependency. Receipt content rules, gift-batch immutability after posting, multi-jurisdiction tax classification — these are real compliance requirements but they live in data and validation, not in real-time API calls to tax authorities. SEPA and DTAUS / MT940 are file-based imports/exports, not live integrations. The slice has compliance content (which the migration must preserve) but not live-regulator risk on the critical path.
2.2 What is in scope for the pilot
| Layer | In-scope artifacts | Translation work |
|---|---|---|
| Web client (Angular 18 target) | 5 controllers + form templates: Gift Batches, Recurring Gift Batches, Motivations Setup, Bank Import, Print Annual Receipts | 5 Angular components with typed services and reactive forms; jQuery + Bootstrap modal patterns rewritten as Angular dialogs |
| Server (.NET 8 target) | TGiftWebConnector + ~23 server-side C# files of gift-domain logic (~15K LOC) |
ASP.NET Web Services (.asmx) RPC endpoints replaced by ASP.NET Core minimal APIs with OpenAPI; TDBTransaction delegate pattern replaced by DbContext + scoped UnitOfWork |
| Typed dataset | GiftBatchTDS (generated from petra.xml) |
Either retire generation pipeline and adopt EF Core code-first, or port the generator output to records + Dapper. See Section 4 for target-architecture decision. |
| Database | Gift / gift-detail / motivation / receipt-related tables; 21 SQL DDL / migration files | Standard EF Core migrations on PostgreSQL; column-name conventions preserved during coexistence per source profile guidance. |
| Report templates | 45 XML report templates (annual receipts, gift reports) | Template-engine port; not hand-coded translation. These are template assets, not interactive UI files. |
2.3 What is out of scope (kept on the legacy side via strangler-fig bridge)
- Partner aggregate — donor identity is consumed by the gift slice (read-only via the strangler bridge to the legacy partner store), not authored. Migrating Partner first would force every other slice to federate through the new identity store and is deferred to a later wave.
- General Ledger postings — the gift slice produces GL posting records via an internal interface. The GL itself remains on the legacy side until a dedicated finance migration wave.
- Bank-account master data — configuration; read-only consumer.
- Donations Processing tag — per the project-intel analysis, this Sage tag is sparse and the real surface lives under Finance — Gift Processing. There is no separate Donations Processing slice to consider.
2.4 Why this pilot is right-sized
- Bigger than the smallest possible pilot, smaller than the largest meaningful one. Sponsorship — Child Management (12 files, 1 screen) is the smallest defensible slice in Petra and was an excellent pure-engineering pilot — but it does not exercise enough UI surface to make Section 7's modernization narrative compelling, and the dominant user-facing surface (a child-photo-upload editor sitting atop a sponsored-child program with photos of minors) is not the right material for a prospect-facing demo. Gift Processing's five screens with end-to-end donor flow are the right size for a pilot that has to also serve as demonstration material.
- Smaller than Finance — Accounting (370 files) or Partner — Contacts Management (211 files) or System Management — Users (390 files). Those are the truly large subsystems whose risk profile rules them out as pilots. Gift Processing's hand-translation surface (~28 files of real translation work) is tractable.
- Pattern reusability across the rest of the programme. Patterns proven on this slice — typed-dataset port, .asmx-to-minimal-API port, jQuery+modal-to-Angular-component port, file-import flows, batch-state machines, multi-screen Angular routing — reapply directly to Finance — Banking, Finance — Accounting, Donations / extracts, and any other multi-screen workflow in the catalog.
See Appendix A for the complete scoring methodology, the 27-subsystem scoring matrix with interactive-UI-surface column, and detailed rationale for why the alternatives were ranked as they were.
3. Legacy System Analysis
The following legacy system analysis was generated by Sage AI.
3.1 High-Level System Architecture
OpenPetra is a layered, n-tier client-server .NET application with a code-generated data-access layer. The browser-based client (JavaScript + jQuery) makes HTTP RPC calls to an ASP.NET Web Services (.asmx, SOAP) layer; the server hosts module-specific Web Connectors (e.g., TSponsorshipWebConnector, TGiftTransactionWebConnector, TBankImportWebConnector) that delegate to typed-dataset ORM classes generated from a master XML schema (petra.xml). A database-abstraction layer fronts PostgreSQL or MySQL.
flowchart TB
subgraph Client["Browser Client (jQuery + HTML)"]
UI["Form Templates & Controllers"]
Axios["HTTP RPC dispatcher"]
end
subgraph Server["ASP.NET Web Services (Mono FastCGI on Linux)"]
SOAP[".asmx SOAP endpoints
(per-module: MFinance, MPartner, MPersonnel, MConference, MSysMan, MReporting)"]
WC["Web Connectors
(T*WebConnector RPC classes)"]
Sec["Security & Session
(TOpenPetraOrgSessionManager,
RequireModulePermission)"]
end
subgraph App["Application Layer"]
Finance["Finance: GL, Gift, AP, Budget,
Currency, Tax (61.6K LOC)"]
Partner["Partner Management
(32.6K LOC)"]
Conference["Conference Management"]
Personnel["Personnel / Volunteers"]
Reporting["Reporting Engine
(XML templates + DTD)"]
end
subgraph Data["Data Layer"]
DAL["Code-generated DAL
(typed datasets + ORM)"]
DBAccess["DBAccess / TDBTransaction"]
Schema["petra.xml master schema
+ generated DDL"]
end
subgraph DB["Database"]
PG["PostgreSQL (primary)"]
MY["MySQL"]
SQ["SQLite"]
end
UI --> Axios
Axios -- "HTTP RPC
JSON / SOAP" --> SOAP
SOAP --> WC
WC --> Sec
WC --> Finance
WC --> Partner
WC --> Conference
WC --> Personnel
WC --> Reporting
Finance --> DAL
Partner --> DAL
Conference --> DAL
Personnel --> DAL
Reporting --> DAL
DAL --> DBAccess
Schema -. "code-gen" .-> DAL
DBAccess --> PG
DBAccess --> MY
DBAccess --> SQ
3.2 Architectural Layers
From Sage's architecture tree, OpenPetra distributes 400,437 cataloged lines across the following top-level layers:
| Layer | Lines | Notable contents |
|---|---|---|
| Application Layer | 116,775 | Finance Management (61.6K), Partner Management (32.6K), Personnel Management, Conference Management, Gift Processing, Reporting, System Administration |
| Presentation Layer | 64,648 | Report Templates (43.5K), Form Templates (7.8K), Localization, Web Client (jQuery, 9.4K) |
| Data Layer | 48,033 | Database Schema (24.9K), Data Access (11.7K), Caching, Typed Datasets, ORM Generation |
| Cross-Cutting Concerns | 40,804 | Validation Framework, Common Utilities, Helper Functions, Printing Framework |
| Infrastructure | 36,977 | Web Services (12.9K), Security Framework (7.2K), Server Configuration, Remoting Framework, Session Management |
| Build & Deployment | 34,112 | Code Generation (12.4K, includes ORM and DDL generation), Development Tools, NAnt build scripts |
| Test | 26,849 | Integration Tests (15.4K), Test Utilities |
| Documentation | 19,354 | Database Documentation including ER diagrams |
| Integration Layer | 12,885 | Bank Import (CAMT, MT940, CSV, ZIP), File Import/Export, ICH Processing, SEPA Export |
Two structural notes worth carrying into target design: (1) a sizeable Code Generation branch (12.4K lines) drives the typed-dataset DAL from petra.xml — this is a build-time pipeline that any modernization must either reproduce, retire, or replace with EF Core scaffolding; (2) the Report Templates branch (43.5K lines of XML reports) is presentation-layer mass that significantly inflates the "UI" footprint relative to the actual interactive forms (the web client itself is ~9.4K lines).
3.3 Data Entities
Sage identifies 48 aggregate roots across 34 bounded contexts with 100% structural evidence. The top entities by relationship count and embedded business-rule density are:
| Aggregate root | Relationships | Rules | Confidence | Domain note |
|---|---|---|---|---|
| Partner | 17 | 15 | 0.91 | Central entity; donors, sponsors, volunteers, organizations, churches, banks all share the Partner record. Family hierarchy uses parent/child ID allocation (0-1 / 2-9). |
| ConferenceAccommodation | 13 | 5 | 0.90 | Room allocation, building/venue infrastructure, attendee lodging. |
| UserAccount | 10 | 8 | 0.91 | Module-scoped permissions across MPartner, MFinance, MPersonnel, MConference; pluggable auth incl. LDAP. |
| PersonnelStaff | 10 | 8 | 0.90 | Commitment lifecycle (SHORT-TERMER, WORKER, LONG-TERMER), assignments, skill matching. |
| Report | 10 | 10 | 0.87 | HTML-template-driven generation, multi-format export (PDF, Excel, HTML). |
| Translation | 10 | 9 | 0.84 | GNU Gettext / PO files; German, English, Norwegian. |
| SponsoredChild | 9 | 8 | 0.86 | Enrollment, status transitions, photo records, GDPR consent. |
| GeneralLedger | 8 | 8 | 0.89 | Chart of accounts, period close, multi-currency, German GDPdU tax export. |
| WebConnector | 8 | 7 | 0.80 | The RPC endpoint pattern itself — treated as an aggregate by Sage. |
| BuildPipeline | 10 | 6 | 0.85 | NAnt + code-gen + AppVeyor + module compilation; modeled as a domain entity in its own right. |
The five highest-cohesion bounded contexts (microservice candidates per Sage):
graph LR PM["PersonnelManagement
(12 entities)"] UAM["UserAccessManagement
(10 entities)"] DA["DatabaseAbstraction
(9 entities)"] FM["FinancialManagement
(9 entities)"] CEM["ConferenceEventManagement
(8 entities)"] Partner((Partner
17 rels)) Partner --- PM Partner --- FM Partner --- CEM Partner --- UAM GL((GeneralLedger)) GL --- FM Conf((ConferenceAccommodation)) Conf --- CEM Staff((PersonnelStaff)) Staff --- PM UA((UserAccount)) UA --- UAM
Note Partner's centrality: it is the connective tissue across PersonnelManagement (volunteers/staff are Partners), FinancialManagement (donors are Partners), ConferenceEventManagement (attendees are Partners), and UserAccessManagement (user accounts link to Partners). Any decomposition strategy must sequence Partner extraction first or accept a shared-data dependency across multiple new services.
3.4 Business Rules & Behavior
This section is populated in detail by the behavioral-rules phase (Section 9). Sage's domain-entity insight reports 265 business rules enforced across the 48 aggregate roots, with an average of 5.5 rules per entity. High-density rule clusters live around Partner (15 rules), Report (10), Translation (9), GeneralLedger (8), PersonnelStaff (8), UserAccount (8), and SponsoredChild (8). The behavioral-rules phase will extract, classify, and assign BR-XXX identifiers to each.
3.5 Technology Stack
| Layer | Technology | Notes |
|---|---|---|
| Server language | C# on .NET Framework 4.7 | 517 files / ~394K LOC weight |
| Server hosting | Mono FastCGI on Linux (fastcgi-mono-server4) under nginx, managed by systemd | Self-hosted; not IIS |
| Server API style | ASP.NET Web Services (.asmx, SOAP) + RPC-style Web Connectors | Module-scoped: serverMFinance.asmx, serverMPartner.asmx, serverMPersonnel.asmx, serverMConference.asmx, serverMReporting.asmx, serverMSysMan.asmx |
| Client | HTML + CSS + JavaScript + jQuery (no SPA framework) | 56 JS files; uses Axios for HTTP RPC; one TypeScript subsystem documented |
| Data access | Code-generated typed datasets driven by petra.xml; TDBTransaction delegate pattern | ~12K lines of generated code; ODBC parameter conversion at runtime |
| Databases | PostgreSQL (primary), MySQL, SQLite | Single abstraction; runtime switching by config |
| Build | NAnt + custom XML/YAML-driven code generation; AppVeyor CI | Generates DAL, DDL, RPC interfaces, and i18n string extraction from petra.xml |
| I18n | GNU Gettext / PO files (German, English, Norwegian) | 2,506 lines of translation files |
| Reporting | XML templates validated against reports.dtd; PDF via Html2Pdf + PdfSharp; Excel via NPOI | 43.5K lines of XML report templates |
| Bank import | CAMT (ISO 20022), MT940 (Swift), CSV, ZIP | SEPA-compliant; ~3.9K lines |
| Auth | Session-based with TOpenPetraOrgSessionManager; pluggable incl. LDAP | Module-permission attributes (RequireModulePermission) |
| Tests | NUnit (~15.4K lines integration tests) + Cypress E2E |
3.6 Integration Patterns
Sage identifies 151 integration points across 216 files with an 89% structural-evidence rate. The dominant pattern is bidirectional Web Connector RPC over HTTP (jQuery client ↔ .asmx server); behind that sit file-based integrations (bank statement formats, XML report templates, code-gen) and database-protocol integrations to PostgreSQL/MySQL. Direction breakdown: 83 bidirectional, 46 inbound, 22 outbound.
| Integration type | Count | % |
|---|---|---|
| file_integration | 42 | 27.8% |
| web_connector | 38 | 25.2% |
| database_integration | 36 | 23.8% |
| internal_rpc | 18 | 11.9% |
| external_service | 9 | 6.0% |
| (other) | 8 | 5.3% |
flowchart LR Browser["jQuery Client
(Axios HTTP RPC)"] Browser -- "POST /api/<module>/<method>" --> SOAP subgraph SOAP["ASP.NET .asmx SOAP layer"] MF["serverMFinance.asmx"] MP["serverMPartner.asmx"] MPe["serverMPersonnel.asmx"] MC["serverMConference.asmx"] MR["serverMReporting.asmx"] MS["serverMSysMan.asmx"] end SOAP --> WCs["Web Connectors
(TGiftTransactionWebConnector,
TSponsorshipWebConnector,
TBankImportWebConnector,
TImportExportWebConnector,
TReportGeneratorWebConnector,
...)"] WCs --> DAL["Typed Datasets / DAL
(generated from petra.xml)"] DAL --> DB[("PostgreSQL / MySQL / SQLite")] Banks[/"Bank statement files
CAMT, MT940, CSV, ZIP"/] --> WCs XMLReports[/"XML report templates
+ reports.dtd"/] --> WCs WCs --> Out[/"Exports: PDF, Excel,
HTML, YML.GZ backup,
SEPA direct debit"/] Systemd[["systemd / fastcgi-mono-server4 / nginx"]] -.hosts.- SOAP
Key observations for migration planning:
- HTTP-routable surface. Every client/server interaction crosses an HTTP RPC boundary — classical strangler-fig is admissible. New Angular/.NET 8 endpoints can be slotted in behind a reverse proxy and swapped slice by slice.
- SOAP is the protocol, not the architecture. Despite the
.asmxwrapping, the calls are JSON-over-HTTP RPC dispatched byTHttpConnector.CallWebConnectorwith module/method routing. The SOAP envelope can be peeled off without changing the call semantics. - High coupling to Partner. 83/151 integrations are bidirectional and the Partner aggregate touches 17 other entities. Service decomposition will cross the Partner boundary repeatedly; expect either a Partner-first extraction or a shared Partner reference data service.
- Code-gen is load-bearing. The DAL and a meaningful share of the RPC surface are generated from
petra.xml. Modernization must decide whether to retain the schema-first generator (port it to T4 or source generators on .NET 8) or replace it with EF Core code-first conventions. - File-format integrations are bank- and report-heavy. CAMT/MT940/SEPA processing and the 43.5K-line XML report library are domain-specific assets, not generic glue — their migration is closer to feature-port than refactor.
Sources: Sage MCP get_project_metadata, get_project_architecture_tree_and_line_count, get_insight (domain_entity, integration), get_project_business_function_subjects, get_project_technology_subjects. Project key petra.
4. Target Architecture
4.1 Introduction
This section specifies the target cloud architecture for the modernized Finance — Gift Processing subsystem. The slice is OpenPetra’s flagship donor money-flow: a donor sends money → a back-office user reconciles a bank-statement import → a gift batch is entered, validated, and posted → motivations classify each line for tax and reporting purposes → the General Ledger receives the posting → at year-end a tax-deductible annual receipt is rendered for every donor. Five interactive web screens (Gift Batches, Recurring Gift Batches, Motivations Setup, Bank Import, Print Annual Receipts) plus a back-office batch-rendering surface (annual-receipt PDFs, SEPA Direct Debit export, ICH international gift export) make up the in-scope footprint.
The target deploys to Azure App Service with Azure Database for PostgreSQL Flexible Server as the primary data store. PostgreSQL is preserved as the database engine — the legacy system is already on it — and the gift-domain schema (a_gift_batch, a_gift, a_gift_detail, a_motivation_group, a_motivation_detail, a_recurring_gift_batch, etc.) is preserved end-to-end. This is a deliberate contrast with a green-field domain like Sponsorship: the gift schema is the system-of-record for donations and posted batches, it’s mature and battle-tested, and it’s shared with downstream finance reporting. The modernization is server-side and client-side, not data-side.
4.1.1 Donor Money-Flow Diagram
Before any technical layering, this is the workflow the new architecture has to support — end-to-end, the same donation moves through these stages:
flowchart LR D["Donor"] BANK["Bank
(CAMT/MT940/CSV)"] BIMP["Bank Import
(reconciliation)"] BATCH["Gift Batch
(unposted → posted)"] MOT["Motivations
(tax + reporting
classification)"] GL["General Ledger
(posting)"] RCPT["Annual Receipt
(PDF, multi-lang)"] SEPA["SEPA Direct Debit
(recurring gifts)"] ICH["ICH Export
(cross-border gifts)"] D -- "donates" --> BANK BANK -- "statement file" --> BIMP BIMP -- "match & create" --> BATCH D -- "standing order" --> SEPA SEPA -- "creates" --> BATCH BATCH -- "uses" --> MOT BATCH -- "post" --> GL BATCH -- "year-end" --> RCPT BATCH -- "cross-border" --> ICH RCPT -. "to donor" .-> D
Five of these stages (Bank Import, Gift Batch, Motivations, Annual Receipt, recurring-gift batch entry that drives SEPA) are interactive web screens in the legacy. Two (the GL posting target and the bank itself) are integration boundaries to subsystems that remain on the legacy side during this migration wave. The new architecture preserves every box and every arrow; what changes is the implementation underneath each interactive box and the way the back-office batch boxes (Annual Receipt, SEPA, ICH) are operated.
4.1.2 Design Principles
- Preserve the gift schema, replace the access layer. The gift-domain tables (
a_gift_batch,a_gift,a_gift_detail, motivation tables, recurring-gift tables) are the system of record for donations and are shared with downstream finance reporting. They stay. The code-generated typed datasetGiftBatchTDS(derived frompetra.xml+ the gift typed-dataset XML) is replaced with EF Core 8 entities mapped to the same physical tables. Net-new tables in this slice are intentionally minimal — in contrast with a green-field domain where the schema is being designed for the first time. - Right-size to the workload shape. The slice has two genuinely different workload shapes — synchronous interactive batch-entry / validation / posting on one side, and asynchronous / batch-mode file-rendering and file-ingestion on the other (annual receipts, SEPA export, CAMT import, ICH export). The target reflects this with two services rather than collapsing both into one. Service granularity is finalized in Appendix B.
- HTTP-routable strangler-fig coexistence. Per Sage’s project intelligence and the .NET / AngularJS source profile’s preferred coexistence pattern, every Petra client/server interaction crosses an HTTP RPC boundary. The Azure-fronted routing tier (Azure Front Door + API Management) directs
/api/gift/*traffic to the new gift services and lets every other module continue to terminate at the legacy Mono FastCGI /.asmxendpoints. Coexistence specifics are owned by Section 11. - Decompose batch rendering off the request path. Annual-receipt PDF generation, SEPA file generation, and bank-statement-file ingestion (ZIP/CAMT) are batch-mode workloads — they should not run synchronously on a user-facing API request. The target places them behind a queued worker (Azure Service Bus + an App Service worker), with the interactive surface returning a job handle and polling for completion.
- GL is consumed, not authored. The General Ledger remains on the legacy side. The new gift-processing service writes posting records to the GL via the existing internal interface (federated REST during coexistence; later, a dedicated GL service migration). The gift slice does not implement GL.
- Donor identity is consumed, not authored. The Partner aggregate (the central entity in the project, 17 relationships, 0.91 confidence per Sage’s project intelligence) is read-only from the gift services for donor identity, addresses, partner-class-driven motivation defaults, and confidential-gift privacy joins. The Partner aggregate is migrated in a later wave; until then, federated reads against the legacy partner store.
- Validation, observability, and error handling at every API boundary. All inbound and outbound calls are validated, traced, and structured-logged. Domain exceptions map to RFC 7807 problem responses. Posted-batch-immutability, sequential-batch-numbering, financial-period-validation, and tax-deductible-percentage bounds checking are implemented as typed domain rules — not return codes — and surface in span attributes when they fire.
4.1.3 Subsystem-specific Scope (committed)
| In scope (modernized in this wave) | Out of scope (left on legacy side, bridged via strangler) |
|---|---|
|
|
4.2 Architecture Diagram
The target is a single Angular 18 SPA that calls two ASP.NET Core 8 services hosted on Azure App Service: a synchronous gift-processing-service for the interactive batch-entry / motivations / recurring-gift surface, and an asynchronous batch-mode gift-receipting-service for annual-receipt rendering, SEPA Direct Debit export, ICH export, and bank-statement-file ingestion. Both services share the gift-domain PostgreSQL schema; rendered files and bank-import staging are externalised to Azure Blob Storage; receipt generation, SEPA export, and CAMT import dispatch through Azure Service Bus to a worker.
flowchart TB
subgraph Browser["Browser"]
NGUI["Angular 18 SPA
(Gift Batches · Recurring ·
Motivations · Bank Import ·
Annual Receipts)"]
end
subgraph Edge["Azure Edge"]
AFD["Azure Front Door
(WAF + TLS)"]
APIM["API Management
(JWT, quota, route table)"]
end
subgraph AppPlan["Azure App Service Plan (Linux, P1v3)"]
GPS["gift-processing-service
ASP.NET Core 8 (Minimal API)
batches · recurring · motivations ·
donor extracts"]
GRS["gift-receipting-service
ASP.NET Core 8 worker
annual receipts · SEPA export ·
ICH export · bank import"]
end
subgraph LegacyZone["Legacy Co-existence Surface (Section 11)"]
LEG["Petra .asmx / Mono FastCGI
(MPartner, MFinance/GL,
System Mgmt, etc.)"]
end
subgraph Data["Data & Storage (Azure)"]
PG[("Azure Database for
PostgreSQL Flexible Server
(gift schema preserved)")]
BLOB[("Azure Blob Storage
(receipts · SEPA files ·
bank-statement uploads)")]
KV["Azure Key Vault"]
REDIS["Azure Cache for Redis
(motivation lookups,
session, idempotency)"]
SB[/"Azure Service Bus
(receipt-batch · sepa-export
· bank-import queues)"/]
end
subgraph Obs["Observability"]
AI["Application Insights"]
LAW["Log Analytics Workspace"]
end
NGUI -- "HTTPS" --> AFD
AFD --> APIM
APIM -- "/api/gift/*" --> GPS
APIM -- "/api/gift/receipts/*
/api/gift/sepa/*
/api/gift/bank-import/*" --> GRS
APIM -- "everything else" --> LEG
GPS -- "EF Core 8 / Npgsql" --> PG
GPS -- "queue jobs" --> SB
SB -- "consume jobs" --> GRS
GRS -- "EF Core 8 / Npgsql" --> PG
GRS -- "rendered PDFs / SEPA XML / staged uploads" --> BLOB
GPS -- "session + ref data" --> REDIS
GPS -- "GL post (REST)" --> LEG
GPS -- "Partner identity (REST)" --> LEG
GPS -- "secrets" --> KV
GRS -- "secrets" --> KV
GPS -- "OTLP / ILogger" --> AI
GRS -- "OTLP / ILogger" --> AI
AI --> LAW
LEG -. "shared DB during
strangler phase" .-> PG
Service granularity (count and boundary) is finalized by the service-architecture phase. This diagram reflects the Section 4 working assumption of two modernized services — a synchronous interactive service and an asynchronous batch worker — based on the genuinely different workload shapes inside the slice. See Appendix B for the resolved decomposition.
4.3 Technology Stack
| Layer | Technology | Configuration / Version | Rationale |
|---|---|---|---|
| Server runtime | .NET 8 LTS (C#) | SDK 8.0.x; ASP.NET Core Minimal APIs + ASP.NET Core background services for the worker | Per the engagement’s server-stack commitment; LTS until Nov 2026 with extended support; preserves the team’s existing C# fluency from the legacy .NET Framework 4.7 codebase. |
| Client runtime | Angular 18 (TypeScript 5.4) | Standalone components; signals; new control flow (@if/@for); RxJS 7; Angular Router with five top-level routes — one per interactive screen | Per the engagement’s client-stack commitment. Five-screen slice exercises Angular Router and lazy-loaded feature modules in a way that a single-screen slice cannot. (Note: Petra itself is jQuery, not AngularJS — the .NET / AngularJS source-profile name reflects the era of this codebase; the client modernization is a green-field rewrite to Angular 18 rather than an AngularJS upgrade.) |
| Compute (interactive) | Azure App Service (Linux), P1v3 plan | Always-on; auto-scale 1–3 instances on CPU>70%; deployment slots (staging/prod) | Managed PaaS reduces operational surface relative to AKS for a slice this size. Hosts gift-processing-service. |
| Compute (batch worker) | Azure App Service (Linux) worker, P1v3 plan | Always-on; auto-scale on Service Bus queue length (KEDA-style scaling) | Hosts gift-receipting-service — PDF rendering for annual-receipt runs is CPU-bursty, SEPA exports are seasonal, and CAMT-import processing is bursty around month-end. Separating the worker plan keeps interactive request latency stable when these batch workloads run. |
| Database | Azure Database for PostgreSQL Flexible Server v16 | General Purpose D2s_v3 (prod); 7-day PITR; geo-redundant backups; read-only replica for donor-extract reporting | Petra already uses PostgreSQL as the primary engine; the gift-domain schema (a_gift_*, a_motivation_*, a_recurring_gift_*) is preserved. Flexible Server is the current-supported tier and supports VNet integration. The read replica isolates heavy donor-extract queries (LIKE-pattern searches across address fields) from the transactional workload. |
| Object storage | Azure Blob Storage (Hot tier) | RA-GRS replication; lifecycle: Hot 90d → Cool 365d → Archive 7y; private endpoint; immutable storage policy on rendered receipts (legal-hold compatible) | Holds rendered annual receipts (PDF; retention required for tax-audit purposes), SEPA Direct Debit XML files, ICH export files, and uploaded bank-statement source files (CAMT/MT940 ZIP). The 7-year archive lifecycle reflects typical receipt retention requirements; a real engagement should confirm against jurisdiction-specific rules. |
| Async messaging | Azure Service Bus (Standard tier) | Three queues: receipt-batch, sepa-export, bank-import; sessions enabled for receipt-batch (per-tenant ordering); 14-day TTL; dead-letter queues | Decouples interactive screens from batch workloads. The Print-Annual-Receipts screen submits a job and shows progress rather than blocking on a multi-thousand-receipt render. SEPA exports queue when the user confirms; CAMT import processing happens off the upload path. |
| Cache | Azure Cache for Redis (Standard C1) | Standard tier; 1GB; persistence off (cache-only) | Holds session state during coexistence, motivation-code reference data (read-heavy: every gift-detail line resolves a motivation), donor-search idempotency keys, and short-lived “most recent batch” lookups for the type-ahead pattern that the legacy uses for gift-batch navigation. |
| API gateway / edge | Azure Front Door (Standard) + Azure API Management (Developer/Basic tier) | Front Door for WAF + TLS; APIM for JWT validation, quota, and the strangler-fig route table | APIM’s policy DSL is the right place to express the routing — /api/gift/* and its sub-paths to the new services, everything else to legacy — without having to touch either the new or the legacy app code. |
| PDF rendering | QuestPDF (commercial license) or PuppeteerSharp (headless Chromium) | One of the two; selection deferred to Section 8 / Appendix C with a single-engine constraint per service | Replaces the legacy HTML5 FileReader-API + server-side template-engine + barcode-encoding pipeline used by Petra’s annual-receipt renderer. QuestPDF is faster and avoids a Chromium dependency; PuppeteerSharp is closer to “render the existing HTML template as-is” and reduces template-rewrite work. The decision affects template-migration cost; both are viable. |
| Barcode generation | BarcodeStandard (Code 128) or equivalent | Pure-managed; no native dependencies | Replaces Petra’s home-grown ASCII-32–126 + 203 barcode encoder (per Sage’s BarcodeCharacterValidationConstraint, conf 0.80). Donor-key barcodes on receipts. |
| SEPA / banking format processing | nuget: ISO20022 packages (CAMT.053 import, PAIN.008 export) or hand-coded XML | Library availability is mixed; selection deferred to Section 8 | CAMT.053 / MT940 / CSV imports for bank statements; PAIN.008 SEPA Direct Debit exports for recurring gifts. Petra’s legacy implementation is hand-coded XML; modern libraries are available but vary in maintenance status. |
| Secrets | Azure Key Vault | RBAC mode; managed identity from App Service; no secrets in app settings | App Service’s Key Vault reference syntax (@Microsoft.KeyVault(...)) keeps DB connection strings, Storage account keys, and Service Bus keys out of source / config files. |
| Identity | Microsoft Entra ID (formerly Azure AD) | OIDC; managed identities for all Azure-resource auth; role-claim mapping for “can post batches” permission | Replaces TOpenPetraOrgSessionManager’s session cookies for new endpoints; legacy session continues to work for legacy endpoints during coexistence. Posting a batch (financial-state-changing) requires an explicit role claim (mapped from the legacy permission table during coexistence). |
| Observability | Application Insights + Log Analytics Workspace | OpenTelemetry SDK for .NET (manual); Application Insights auto-instrumentation for ASP.NET Core, Service Bus, and Npgsql; structured logs via ILogger<T> + Serilog JSON sink | Aligned with the OTel-based skill defaults but using Azure-native sinks. Cross-service trace correlation works automatically once OTel exporter is configured against the App Insights endpoint — queueing a receipt job from the interactive service and consuming it on the worker will appear as a single distributed trace. |
| Build / CI | GitHub Actions (preferred) or Azure DevOps Pipelines | dotnet test, dotnet publish, npm run build (Angular), Bicep / Terraform for IaC, container image build for App Service Linux; per-service deploy stages | Replaces the legacy NAnt + custom code-generation pipeline. The petra.xml-driven typed-dataset generator is retired for this slice — the existing a_gift_* tables are the source of truth and EF Core entities are scaffolded against them. Deferred decision: whether to keep petra.xml generation alive for the legacy zones during coexistence is a Section 11 concern. |
| Testing | xUnit + FluentAssertions + Testcontainers (server); Jasmine + Cypress (client) | Coverage target 80%; integration tests run against ephemeral Postgres (and a real Service Bus emulator for queue tests) via Testcontainers | xUnit is the de-facto standard for .NET 8; Testcontainers gives real-Postgres test fidelity that the legacy SQLite-fallback test pattern lacked. Service Bus emulator coverage matters because the receipt-batch / SEPA / bank-import paths are queue-mediated. |
| i18n | Angular i18n (client) + .resx resource files (server) + per-locale receipt templates (Blob) | Locales: en, de, nb, plus Danish for compliance per totalgiftsperdonor.xml evidence | Source PO files are converted at build time; runtime is fully .NET / Angular-native. Receipt templates are per-locale assets stored in Blob and selected at render time based on donor locale preference. |
4.3.1 Code-Level Architecture Decisions
The resolved target patterns below combine: (a) defaults from Sage’s target-architecture pattern library (which assume a Python target by default and so were translated to their .NET 8 / Angular 18 equivalents pragmatically), (b) Azure App Service-specific overrides inferred from analogous container-platform templates, and (c) the engagement’s explicit target-architecture commitments (no plan-level pattern overrides applied).
| Category | Decision | Library / Tool | Rationale |
|---|---|---|---|
| API framework | HTTP/JSON via ASP.NET Core 8 Minimal APIs with controller-style fallback for complex routes (donor-extract dynamic-SQL, multi-step batch-post) | Microsoft.AspNetCore.App | Minimal APIs are the .NET 8 idiom; controllers are still available where attribute-based routing is clearer. Both gift services use the same shape. |
| Validation | Request DTOs validated at all API boundaries; financial-period-validation, posted-batch-immutability, tax-deductible-percentage-bounds (0–100), sequential-batch-numbering enforced as typed domain rules above the validation layer | FluentValidation for shape; custom domain-rule classes for financial constraints | Equivalent role to Pydantic in the Python defaults: validate every inbound boundary. Domain rules sit above field-level validation because they require database state (last-batch-number, batch-status, accounting-period-status). |
| Logging | Structured JSON logs with correlation IDs propagating across HTTP and Service Bus boundaries | Microsoft.Extensions.Logging + Serilog with the Serilog.Sinks.ApplicationInsights sink and Serilog.Formatting.Compact JSON formatter | Equivalent role to structlog in the Python defaults. Trace IDs propagate via System.Diagnostics.Activity (W3C Trace Context) — the same trace ID appears on the interactive request, the queued message, and the worker’s render span. |
| Tracing | OpenTelemetry, exported to Application Insights; Service Bus auto-instrumentation enabled so a receipt-job trace spans both services | OpenTelemetry.Extensions.Hosting, Azure.Monitor.OpenTelemetry.AspNetCore, Azure.Messaging.ServiceBus built-in tracing | Vendor-neutral instrumentation, Azure-native sink. Auto-instruments HTTP, Npgsql, Redis, and Service Bus. |
| Metrics | OpenTelemetry metrics, exported to Application Insights | Same OTel hosting bundle as tracing | Standard metrics: request count, request duration, plus business counters — gift batches posted, gift detail lines created, motivations resolved, annual receipts rendered, SEPA mandates exported, bank-import transactions matched / unmatched. |
| Health checks | Readiness, liveness, startup probes on both services | Microsoft.Extensions.Diagnostics.HealthChecks with AspNetCore.HealthChecks.NpgSql, AspNetCore.HealthChecks.Redis, AspNetCore.HealthChecks.AzureStorage, AspNetCore.HealthChecks.AzureServiceBus | App Service uses readiness for backend-pool health at APIM and Front Door. The probe paths are also a portability hedge: if the slice is ever repackaged into AKS / Container Apps, no app-side change is needed. |
| Error handling | Domain exceptions with RFC 7807 problem responses; gift-domain exception subclasses (e.g., BatchAlreadyPostedException, FinancialPeriodClosedException, TaxDeductibleOutOfBoundsException, UnknownMotivationException) mapped to specific HTTP statuses | Custom GiftServiceError hierarchy + app.UseExceptionHandler middleware producing application/problem+json | Replaces the legacy “return code from Web Connector” pattern. The financial constraints in the slice (sequential-batch-numbering, posted-batch-immutability, period-validation, tax-deductible-bounds) become typed exceptions rather than scattered if-blocks returning numeric codes. |
| Database access | Entity Framework Core 8 (code-first against existing schema, scaffolded then maintained by hand); Dapper for the high-volume donor-extract queries that need raw-SQL parameterization | Microsoft.EntityFrameworkCore, Npgsql.EntityFrameworkCore.PostgreSQL, EF Core Migrations, Dapper | Replaces the legacy XML-driven typed dataset generator (GiftBatchTDS) for the slice. The existing physical schema (a_gift_batch, a_gift, a_gift_detail, a_motivation_*, a_recurring_gift_*) is the source of truth; EF Core entities are scaffolded against it. Dapper is reserved for the dynamic-SQL donor-extract queries (the ##motivation_group_detail_pairs## placeholder pattern in the legacy SQL files), where EF Core’s LINQ surface adds friction. |
| Connection pooling | Application-level pool | Npgsql built-in pooling (default MinPoolSize=0, MaxPoolSize=100) | App Service is not Lambda; connections persist between requests. Worker service pool sized smaller (MaxPoolSize=20) to avoid starving the interactive service during peak receipt runs. |
| Inter-service comms (sync) | REST/HTTP/JSON for federated reads to legacy MPartner and MFinance | HttpClientFactory + Polly for retries with exponential backoff | Same protocol as the strangler-fig boundary. Polly handles transient legacy failures (Mono FastCGI restarts, etc.) without surfacing them to the interactive user. |
| Inter-service comms (async) | Azure Service Bus — required for this slice (not deferred as in the Sponsorship slice) | Azure.Messaging.ServiceBus | The receipt-batch, SEPA-export, and bank-import flows are first-class queued workloads. Service Bus sessions are used on receipt-batch to preserve per-ledger ordering during a year-end run. Dead-letter queues feed an admin retry surface in the interactive UI. |
| Configuration | App Service application settings, Key Vault references; per-environment Bicep parameter files | Microsoft.Extensions.Configuration | 12-factor; secrets resolved at boot via managed identity. |
| Secrets backend | Azure Key Vault | App Service Key Vault reference syntax | Equivalent to AWS Secrets Manager in the AWS templates. |
| Object storage | Azure Blob Storage SDK | Azure.Storage.Blobs | Receipts (PDF), SEPA exports (XML), ICH exports, and bank-statement uploads. Container layout: receipts/{ledger_number}/{year}/{donor_partner_key}/{receipt_id}.pdf; immutability policy on receipts/ for tax-audit defensibility. |
| API documentation | OpenAPI 3 (Swagger) | Swashbuckle.AspNetCore (or built-in Microsoft.AspNetCore.OpenApi on .NET 8) | Replaces the legacy “read the .asmx WSDL” pattern. The OpenAPI document is generated for both gift services and merged at the APIM gateway. |
| Testing framework | xUnit + FluentAssertions + Testcontainers (.NET); Jasmine/Karma + Cypress (Angular) | Coverage target 80%; gift-batch posting and tax-deductible calculation are required golden-master test areas (legacy outputs captured against the modern outputs row-by-row) | Equivalent to pytest in the Python defaults. Golden-master tests over a frozen demo dataset are essential for any financial migration — numbers must match to the cent. |
Pattern resolution notes:
- Sage’s target-architecture pattern library defaults to a Python target (
fastapi,pydantic,structlog,sqlalchemy). For this C# / Angular target every default required pragmatic translation to its .NET 8 / Angular 18 equivalent. - No Azure App Service-specific platform template exists in the standard library; decisions above are inferred from the analogous container-platform templates plus Azure-platform best practice.
- Async messaging (Azure Service Bus) is required for this slice — receipt batch rendering, SEPA export, and bank-import file processing are queued workloads, not deferrable.
4.4 Data Model
This slice has a fundamentally different data-model story from a green-field domain like Sponsorship. The gift-domain schema is preserved, not redesigned. The existing 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) are the system of record for OpenPetra donations: they hold posted financial transactions, they’re shared with downstream finance reporting, and they have years of operational history. The migration replaces the access layer (typed datasets → EF Core) and the API surface (.asmx Web Connector → ASP.NET Core endpoints) without disturbing the physical schema.
Net-new tables in this slice are deliberately minimal. We add only what’s needed to support the queued-worker pattern (a job-tracking table) and to capture metadata that the legacy stored only in filesystem paths or session state (rendered-receipt and uploaded-statement provenance).
4.4.1 Gift-domain ER (preserved physical schema)
erDiagram
PARTNER ||--o{ GIFT_BATCH : "gl_effective"
PARTNER ||--o{ GIFT : "donor_partner_key"
PARTNER ||--o{ GIFT_DETAIL : "recipient_partner_key"
GIFT_BATCH ||--o{ GIFT : "contains"
GIFT ||--o{ GIFT_DETAIL : "has lines"
MOTIVATION_GROUP ||--o{ MOTIVATION_DETAIL : "categorises"
MOTIVATION_DETAIL ||--o{ GIFT_DETAIL : "classifies"
RECURRING_GIFT_BATCH ||--o{ RECURRING_GIFT : "contains"
RECURRING_GIFT ||--o{ RECURRING_GIFT_DETAIL : "has lines"
PARTNER ||--o{ RECURRING_GIFT : "donor_partner_key"
PARTNER {
bigint partner_key PK "legacy p_partner.p_partner_key_n"
string partner_class "FAMILY|PERSON|ORG"
string partner_short_name
string status_code
}
GIFT_BATCH {
int ledger_number PK "FK to a_ledger"
int batch_number PK "sequential per ledger"
date batch_effective_date
string batch_status "Unposted|Posted|Cancelled"
string currency_code "ISO 4217"
decimal exchange_rate_to_base
string method_of_payment
string created_by
datetime created_at
datetime posted_at "nullable"
}
GIFT {
int ledger_number PK
int batch_number PK
int gift_transaction_number PK
bigint donor_partner_key FK
string method_of_giving
date date_entered
boolean confidential
string receipt_letter_code
boolean receipt_print_flag
}
GIFT_DETAIL {
int ledger_number PK
int batch_number PK
int gift_transaction_number PK
int detail_number PK
bigint recipient_partner_key FK
int recipient_ledger_number
string motivation_group_code FK
string motivation_detail_code FK
decimal gift_transaction_amount "transaction currency"
decimal gift_amount "base currency"
decimal gift_amount_intl "intl currency (ICH)"
decimal tax_deductible_pct "0..100"
boolean modified_detail "for adjustment-filtering"
}
MOTIVATION_GROUP {
string motivation_group_code PK
string motivation_group_description
boolean group_status
}
MOTIVATION_DETAIL {
string motivation_group_code PK
string motivation_detail_code PK
string motivation_detail_description
boolean tax_deductible
string default_account_code "GL account"
string default_cost_centre_code
}
RECURRING_GIFT_BATCH {
int ledger_number PK
int recurring_batch_number PK
string description
string frequency "Monthly|Quarterly|Annual|..."
}
RECURRING_GIFT {
int ledger_number PK
int recurring_batch_number PK
int recurring_gift_number PK
bigint donor_partner_key FK
date start_date
date end_date "nullable"
boolean active
string sepa_mandate_reference "varchar(35)"
date sepa_mandate_given "nullable"
}
RECURRING_GIFT_DETAIL {
int ledger_number PK
int recurring_batch_number PK
int recurring_gift_number PK
int detail_number PK
bigint recipient_partner_key FK
string motivation_group_code FK
string motivation_detail_code FK
decimal gift_amount
string currency_code
}
4.4.2 Net-new tables (gift-services-owned)
These are the only schema additions in this wave. They support the queued-worker pattern and capture provenance the legacy stored ad-hoc.
erDiagram
RENDER_JOB ||--o| RENDERED_RECEIPT : "produces"
RENDER_JOB ||--o| SEPA_EXPORT_FILE : "produces"
BANK_IMPORT_UPLOAD ||--o{ BANK_IMPORT_LINE : "extracts"
BANK_IMPORT_LINE ||--o| GIFT_DETAIL : "matches (optional)"
RENDER_JOB {
uuid job_id PK
string job_kind "ANNUAL_RECEIPT|SEPA_EXPORT|ICH_EXPORT"
int ledger_number
int year "for annual receipts"
string status "Queued|Running|Succeeded|Failed"
string requested_by_user_id
datetime requested_at
datetime completed_at "nullable"
text failure_reason "nullable"
}
RENDERED_RECEIPT {
uuid receipt_id PK
uuid job_id FK
int ledger_number
int year
bigint donor_partner_key FK
string locale_code "en|de|nb|da|..."
string blob_uri "Azure Blob URI"
bigint size_bytes
string sha256
datetime rendered_at
datetime immutable_until "tax-audit retention"
}
SEPA_EXPORT_FILE {
uuid sepa_file_id PK
uuid job_id FK
int ledger_number
int recurring_batch_number
date collection_date
string blob_uri
int mandate_count
decimal total_amount
string currency_code
datetime exported_at
}
BANK_IMPORT_UPLOAD {
uuid upload_id PK
string original_filename
string format "CAMT|MT940|CSV|ZIP"
string blob_uri "raw file in Blob"
bigint size_bytes
string uploaded_by_user_id
datetime uploaded_at
string status "Parsed|MatchingComplete|Discarded"
}
BANK_IMPORT_LINE {
uuid line_id PK
uuid upload_id FK
int line_number
date transaction_date
decimal amount
string currency_code
string remitter_name
string remitter_iban
text reference_text
int matched_ledger_number "nullable"
int matched_batch_number "nullable"
int matched_gift_transaction_number "nullable"
string match_status "Unmatched|AutoMatched|UserMatched|Rejected"
}
4.4.3 Table-by-table sketch
| Table | Owner | Notes |
|---|---|---|
a_gift_batch, a_gift, a_gift_detail | Gift services (preserved schema) | Existing tables. Mapped to EF Core entities; the legacy generated typed dataset (GiftBatchTDS) is retired for the slice but the underlying physical tables are unchanged. batch_status remains the source of truth for posted-batch-immutability rules; modified_detail remains the marker the adjustment workflow uses to avoid double-processing. |
a_motivation_group, a_motivation_detail | Gift services (preserved schema) | Existing tables. The chart of motivations — what each gift detail line is “for” — with default GL account / cost-centre codes per detail and a tax-deductible flag. Read-heavy (every gift detail resolves a motivation); cached in Redis. |
a_recurring_gift_batch, a_recurring_gift, a_recurring_gift_detail | Gift services (preserved schema) | Existing tables. Standing-order-style recurring gifts. SEPA mandate columns (a_sepa_mandate_reference_c varchar(35), a_sepa_mandate_given_d date) live on a_recurring_gift — the per-gift row, not the batch header — and are the source for SEPA Direct Debit export (per Sage’s SEPAMandateReferenceFormatConstraint, conf 0.89: format is donor-key + YYYYMMDD). |
render_job | Gift receipting service (NEW) | Tracks queued / running / completed batch jobs (annual-receipt rendering, SEPA export, ICH export). Replaces the legacy “render runs synchronously on a single web request” pattern. |
rendered_receipt | Gift receipting service (NEW) | Provenance row for every rendered annual-receipt PDF. Binary lives in Azure Blob with an immutability policy. The legacy stored receipts as ephemeral file-system temp files; this is a real audit trail. |
sepa_export_file | Gift receipting service (NEW) | Provenance row for every SEPA Direct Debit XML file. Binary in Blob. |
bank_import_upload / bank_import_line | Gift receipting service (NEW) | Captures the raw bank-statement upload (CAMT/MT940/CSV/ZIP) and the parsed transaction lines. Match status tracks which lines have been auto-matched to expected gifts, which were user-matched manually, and which remain unmatched. Replaces legacy session-state-driven import flow. |
p_partner, p_family, p_person | Legacy MPartner | Read-only from gift services. Donor identity, addresses, partner-class. Mapped via EF Core view-entities. During the strangler phase this is a cross-schema read on the same Postgres instance; later, when MPartner itself is migrated, this becomes a federated REST read. |
a_ledger, a_account, a_cost_centre, a_accounting_period | Legacy MFinance / GL | Read-only from gift services. Used for ledger validation, period-open checks, and motivation-detail GL-account / cost-centre defaults. The actual GL posting happens via federated REST to the legacy MFinance module — the gift slice does not write to a_transaction or a_journal. |
4.4.4 Indexing strategy
a_gift_batch (ledger_number, batch_status, batch_effective_date)— supports the “unposted batches in this period” landing query and the field-adjustment filter onbatch_status = 'Posted'.a_gift_detail (recipient_partner_key, modified_detail)— supports adjustment-processing’s recipient-side queries (perUnmodifiedDetailFilterConstraint, conf 0.89).a_gift (donor_partner_key, date_entered DESC)— supports donor-history queries used by the receipt printer (perGift.ReceiptPrinting.GetDonationsOfDonor.sqlevidence).a_motivation_detail (motivation_group_code, motivation_detail_code)— primary-key access pattern, plus a covering index on(default_account_code, default_cost_centre_code)for the cross-reference report queries.render_job (status, requested_at)— supports the worker’s “next queued job” poll and the admin “recent failed jobs” list.bank_import_line (upload_id, match_status)— supports the user-facing “unmatched transactions” queue.- Trigram or pg_trgm index on partner-name fields used by donor-extract LIKE-pattern searches (per the legacy
TypeAheadSearchPattern, conf 0.85). The legacy implementation does multi-field LIKE without index support; PostgreSQLpg_trgmbrings these queries into sub-second territory at production volumes.
For detailed schema mappings from legacy petra.xml-generated typed datasets and the existing PostgreSQL a_gift_* / a_motivation_* DDL to this target data model (and the six net-new operational tables), see Section 10: Data Mapping Strategy.
4.5 Service Architecture
Service architecture is finalized in Appendix B (Service Architecture).
This section’s working assumption — carried into Appendix B for reconciliation — is two modernized services, deployed as two ASP.NET Core 8 applications on Azure App Service:
gift-processing-service— the synchronous interactive surface. Modules:- BatchModule — gift batches + transactions + details: create, validate, post, reverse / adjust
- RecurringModule — recurring gift batches, frequency scheduling, SEPA mandate-reference generation
- MotivationModule — the chart of motivation groups and motivation details
- DonorExtractModule — donor-extract queries (by amount, by motivation, by recipient field, etc.)
- FederationModule — thin Partner identity reads + ledger / period / account reads against legacy MFinance
gift-receipting-service— the asynchronous batch-mode worker. Modules:- AnnualReceiptModule — queue-driven annual-receipt PDF rendering, multi-language template selection, Code-128 barcode encoding, Blob upload with immutability
- SepaExportModule — queue-driven PAIN.008 SEPA Direct Debit XML generation
- IchExportModule — queue-driven International Clearing House cross-border gift export
- BankImportModule — queue-driven CAMT / MT940 / CSV / ZIP parsing and transaction matching against expected gifts
The two services are split because the workload shapes are genuinely different — the interactive service is request-response under a sub-second p95 latency budget, while the batch worker handles minute-to-hour-long jobs (tens of thousands of receipts in an annual run). Putting them in one process would either starve interactive requests during batch runs or under-utilise compute at quiet times. Putting them on separate App Service plans gives us independent scaling.
This decomposition reflects the slice’s actual workload-shape diversity. The service-architecture analysis (Appendix B) has the final word.
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 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 slice (five interactive screens: Gift Batches, Recurring Gift Batches, Motivations Setup, Bank Import, Print Annual Receipts) plus the back-office batch surface that serves them — covering the web client, application-server hosting, batch / queued-worker boundary, identity, build chain, and data-binding layers that the target architecture rebuilds.
Source: Curated from Section 6 (Technical Debt Analysis) with forward-platform-unlock framing — specifically the architectural concerns catalog (12.2) and the outdated-dependency audit (12.7), supplemented by the target-architecture handoff (Section 4) for target-side framing.
Note: For UI-specific platform affinity analysis at the screen-transformation level (per-screen modernization, layout shifts across the five gift screens), see Section 7: UI/UX Transformation Examples. Section 5.3 covers UI platform constraints (framework ceilings, build-chain deprecation); Section 7 covers UI transformation (per-screen rebuilds).
Platform affinity analysis classifies each constraint discovered in the legacy codebase as either platform-driven (an artifact of the .NET Framework 4.7 / Mono FastCGI / jQuery / NAnt environment that serves no business purpose) or business-driven (a genuine requirement that must be preserved). Getting this distinction wrong leads to one of two failure modes: replicating unnecessary limitations in the modern system, or accidentally breaking business rules that were disguised as platform constraints.
Petra is a semi-modern .NET codebase — not terminally legacy, but decaying along identifiable axes (deprecated frameworks, security-relevant version drift, runtime-imposed dependency ceilings). Section 6 surfaced 7 architectural concerns and audited 37 dependencies; this section curates the subset where the target architecture (.NET 8 + Angular 18 on Azure App Service, with Microsoft Entra ID, PostgreSQL Flexible Server, and Azure Service Bus) breaks a constraint that the legacy stack imposes. The dominant patterns that recur across most entries are two strategic ceilings working in tandem: the .NET Framework 4.7 runtime ceiling (collapses a long tail of NuGet pins and polyfills the moment the slice moves to .NET 8) and the synchronous in-process request pipeline (collapses the annual-receipt-run and bank-import flows from request-monopolising operations into queued, independently-scaled workloads).
5.1 Capacity Constraints
5.1.1 .NET Framework 4.7 NuGet Ecosystem Ceiling
| Aspect | Legacy System (.NET Framework 4.7 / Mono on Linux) | Target System (.NET 8 on Azure App Service Linux) |
|---|---|---|
| Discovery |
Source: Section 6 D-01 (high) + Section 6.7 server-side dependency table; manifest comments in csharp/ThirdParty/packages.configRoot Cause: The .NET Framework 4.7 runtime is past mainstream Microsoft support and pins the dependency catalog to versions that predate .NET 5+. Multiple deps carry inline manifest comments documenting the inability to upgrade:
Additionally, six BCL polyfill packages (System.Buffers, System.Memory, System.ValueTuple, System.Threading.Tasks.Extensions, System.Runtime.CompilerServices.Unsafe, System.Runtime) and Microsoft.Bcl.AsyncInterfaces are present only because the runtime is .NET Framework 4.7 — they are absorbed into the BCL on .NET 8.
|
.NET 8 LTS removes the runtime ceiling: ~14 of the 37 audited deps either collapse into the BCL (BCL polyfills) or upgrade naturally to current major versions (Npgsql 4→8, NUnit 3.13→xUnit). The dependency catalog stops being a NuGet-version-pin minefield. |
| Legacy Behavior | The dependency catalog is one critical CVE away from a forced cross-major-version migration with no easy rollback path. Every NuGet upgrade requires checking compatibility against .NET Framework 4.7 first, and the most-impactful upgrades (Npgsql 5+, NUnit 3.16+, SharpZipLib 1.4+) are gated entirely. Security-relevant packages (Npgsql, the database driver behind every gift-domain query) carry years-old version pins that the team cannot lift without a runtime migration. | Both gift services run on .NET 8 LTS with current major-version dependencies (Npgsql.EntityFrameworkCore.PostgreSQL 8.x, xUnit, System.IO.Compression built-in for the SharpZipLib role). New deps target the supported platform; security patches land via standard NuGet upgrade rather than via cross-major-version forced migrations. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Move both gift services to .NET 8 LTS; let the dependency cascade collapse Rationale: The .NET Framework 4.7 runtime is the single root cause behind a long tail of NuGet-ecosystem version pins. The pins are not 35 independent decisions — they are one strategic ceiling. Moving the slice to .NET 8 dissolves the ceiling for the new code path. Legacy zones outside the slice retain the ceiling until their own slices migrate, per Section 11. The gift slice retires this ceiling for Npgsql 4.1.10 specifically — the project's primary database driver and the main blocker on the Sage-flagged headline 12.7 entry. Implementation: Both gift-processing-service and gift-receipting-service target net8.0. Server-side deps in the slice: Npgsql.EntityFrameworkCore.PostgreSQL 8.x, System.Text.Json 8.x (built-in), xUnit, FluentAssertions, Testcontainers. Polyfill packages are removed entirely. Outside the slice, packages.config and the .NET 4.7 pins persist during coexistence.
|
|
5.1.2 SharpZipLib 1.3.3 + PDFsharp 1.50 — In-Slice Library Ceilings
| Aspect | Legacy System (.NET Framework 4.7 / Mono on Linux) | Target System (.NET 8 on Azure App Service Linux) |
|---|---|---|
| Discovery |
Source: Section 6.7 server-side dependency table — SharpZipLib 1.3.3 (outdated, replaced by target) and PDFsharp 1.50.5147 (eol-major-version, replaced by target). Both are inside the working surface for the gift-processing slice because the bank-import worker and annual-receipt rendering are in scope.Root Cause: SharpZipLib 1.3.3 is required by the bank-import flow, which ingests ZIP-wrapped CAMT bank-statement archives; the manifest comment documents that 1.4.x cannot be adopted because it requires .NET 6.0. PDFsharp 1.50.5147 drives annual-receipt PDF rendering — the 1.50 line dates from 2018, predates the 6.x current line by years, and was authored against the .NET Framework System.Drawing GDI+ surface that itself is no longer fully cross-platform on .NET 8 Linux. Both libraries have been load-bearing for the gift slice's heaviest workloads, and both have hit their ceiling on the legacy runtime.
|
SharpZipLib's archive role is filled by System.IO.Compression (built-in to .NET 8 BCL), eliminating the third-party dependency entirely. PDFsharp 1.50 is replaced by either QuestPDF (managed-code, declarative, .NET 8 native) or PuppeteerSharp (headless-Chromium HTML→PDF) — final selection deferred to Section 8. Both candidates ship modern font support and run cleanly on Linux containers. |
| Legacy Behavior | The team has accepted "we are stuck on the 2018-era PDF library because moving forward requires moving runtimes." When a donor reports that a receipt's accented donor name renders incorrectly, the fix path runs through PDFsharp 1.50's font subsystem — not a current-version supported surface. When a bank delivers CAMT statements with a newer ZIP variant, the fallback is hand-coded extraction rather than a library upgrade. Both libraries are quiet maintenance-debt that compounds with every audit cycle. | Annual receipts render through a current-major-version PDF library on a supported runtime. Bank-import ZIP handling uses BCL primitives that are first-party Microsoft. Library upgrades follow the standard NuGet cadence; both libraries are off the maintenance-debt list for the gift slice. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace SharpZipLib with built-in System.IO.Compression; replace PDFsharp 1.50 with QuestPDF or PuppeteerSharpRationale: Both libraries are in scope only because the gift-processing slice does ZIP I/O (bank-import worker) and PDF rendering (annual-receipt worker). Both libraries are gated on the runtime — the 1.4.x SharpZipLib line and the 6.x PDFsharp line both require .NET 6+. Moving the slice to .NET 8 unlocks both replacements simultaneously. There is no business reason to carry forward the legacy versions; the receipt-content rules (Code-128 barcode set, multi-language receipt templates, tax-deductible-percentage display) are renderer-agnostic. Implementation: gift-receipting-service uses System.IO.Compression for bank-import archive extraction and either QuestPDF or PuppeteerSharp for receipt rendering. SHA-256 hashes of rendered PDFs are written to the rendered_receipt provenance table; binaries land in the immutable receipts/ Blob container. Final renderer pick belongs to Section 8.
|
|
5.2 Processing Model Constraints
5.2.1 Synchronous In-Process Request Pipeline for Batch Workloads
⚠️ HEADLINE PLATFORM-UNLOCK FOR THE GIFT-PROCESSING SLICE
This is the single most stakeholder-impactful entry in Section 5 for this slice. The annual-receipt run, SEPA export generation, and bank-statement ingestion are each capable of monopolising a request thread for minutes (or hours, at year-end). The legacy synchronous request pipeline ties them to interactive request-response patterns; the target separates them onto Azure Service Bus queues with an independently-scaled worker. This is what changes most visibly for finance-team users.
| Aspect | Legacy System (.NET Framework 4.7 / Mono on Linux) | Target System (.NET 8 on Azure App Service Linux) |
|---|---|---|
| Discovery |
Source: Section 6 D-02 (medium — Mitigated by target, "Multi-currency calculations + automated workflows = potential perf bottleneck") and Section 6 D-03 (medium — Mitigated by target, "XML report templates = 14% of codebase, maintenance overhead"); target-architecture handoff service decomposition rationale (sub-second p95 interactive vs minutes-to-hours batch); profile legacyIdiomInventory (.asmx + SOAP envelope; THttpConnector.CallWebConnector RPC pattern)Root Cause: Petra's .asmx Web Services dispatch every operation — including the heaviest batch workloads — through the same synchronous in-process request pipeline. The annual-receipt run iterates donors, renders each PDF via Gift.Receipting.cs (1,794 LOC), holds a transaction across the iteration, and returns when the last receipt is written. SEPA export generation in Gift.Exporting.SEPA.cs (369 LOC) and bank-statement ingestion in Gift.Importing.cs (1,998 LOC) follow the same shape. Each of these can monopolise a thread for minutes; at year-end, hours. There is no native job-queue model, no per-tenant worker pool, no failure-isolation between interactive batch entry and back-office batch rendering — they share the same Mono FastCGI worker, same connection pool, and same request pipeline.
|
ASP.NET Core 8 with Azure Service Bus on three queues (receipt-batch, sepa-export, bank-import). The gift-processing-service handles sub-second interactive traffic on its own App Service plan; the gift-receipting-service consumes Service Bus messages on an independent plan with its own scale rules. Sessions enabled on receipt-batch for per-ledger ordering; dead-letter queues feed an admin retry surface; render_job table tracks queued / running / completed status. OpenTelemetry auto-instrumentation makes a receipt-job trace span both services. |
| Legacy Behavior | Running an annual-receipt batch on the day a finance user is also entering a gift batch produces user-visible contention — interactive forms compete with the receipt run for thread pool, database connections, and response budget. The receipt run itself is request-bound: a browser holds the page open while the server iterates donors. Failure mid-run leaves the database in a hard-to-recover state because there is no per-receipt provenance row. Capacity planning for year-end is "make sure the batch finishes before someone needs the system" rather than "scale the worker pool." | The annual-receipt run becomes a job submission: the admin posts a run request, gets a job ID back instantly, and watches status update in the UI as messages flow through Service Bus to the worker. Interactive gift-batch entry on gift-processing-service is unaffected by an active receipt run on gift-receipting-service — they're on independent App Service plans with independent connection pools. Each rendered PDF gets its own rendered_receipt row with SHA-256 hash and Blob URI; mid-run failure is recoverable per receipt, not per batch. Year-end capacity is a Service Bus queue depth metric driving auto-scale — "make the worker pool elastic" rather than "schedule around quiet times." |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Split the slice into gift-processing-service (sync, interactive) and gift-receipting-service (async, queued worker); use Azure Service Bus as the boundaryRationale: The synchronous-in-process model is the single biggest forward-platform-unlock the slice ships. There is no business reason for the annual-receipt run to be request-bound — nobody is interactively waiting on it; the constraint is purely an artifact of the .asmx pipeline shape. Splitting interactive from queued workloads onto independent App Service plans gives the slice independent scaling, independent failure modes, and per-receipt provenance that the legacy shape cannot offer. Service Bus's session feature preserves per-ledger ordering where the gift-domain rules require it (sequential batch numbering); auto-instrumented tracing preserves the cross-boundary observability that the synchronous version had implicitly via stack traces. Implementation: Three Service Bus queues (Standard tier): receipt-batch with sessions for per-ledger ordering, sepa-export, bank-import. The worker is an ASP.NET Core 8 background service. The render_job table tracks job lifecycle (queued / running / completed / failed); the rendered_receipt, sepa_export_file, and bank_import_upload tables hold per-job provenance with Blob URIs and SHA-256 hashes. Dead-letter queues feed an admin retry surface. OpenTelemetry instrumentation propagates trace IDs across the HTTP-to-Service-Bus boundary so a receipt-job trace spans both services.
|
|
5.2.2 Mono FastCGI Hosting on Linux
| Aspect | Legacy System (.NET Framework 4.7 / Mono on Linux) | Target System (.NET 8 on Azure App Service Linux) |
|---|---|---|
| Discovery |
Source: Section 6 D-07 (low — Retired by target); project-intel handoff (hosting fact); profile knownEolDependencies (Mono FastCGI — effectively abandoned)Root Cause: Petra hosts the .NET application server via Mono fastcgi-mono-server behind nginx + systemd, because .NET Framework 4.7 has no first-party Linux runtime. The Mono project deprioritized FastCGI server work years ago; the component is community-maintained at best. The ASP.NET request pipeline, the .asmx Web Services dispatcher, and TPetraPrincipal session storage all live inside the Mono FastCGI worker process — a single in-process container with no native horizontal scaling, no managed deployment platform, and minimal observability tooling. The same single-process container is the chokepoint that produces the synchronous-pipeline issues catalogued in 5.2.1.
|
Kestrel-hosted ASP.NET Core 8 on Azure App Service (Linux, P1v3). First-party Microsoft runtime, managed scale-out, App Service deployment slots, native Application Insights + OpenTelemetry instrumentation. Front Door + APIM in front; managed identity to Azure resources behind. Two App Service plans (one per gift service) give the workload-shape separation that the single Mono FastCGI worker cannot. |
| Legacy Behavior | Operations have one process to scale and one host machine model to reason about. Deployment is manual or scripted. The hosting layer offers no baseline for tracing, metrics, or correlated logs — observability has to be bolted on. Capacity planning means provisioning whole machines; there is no "throw a slot at it" moment, and there is no way to scale the receipt-rendering workload independently of interactive gift-batch entry. | App Service handles autoscale, deployment slots, blue/green swaps, and platform telemetry by default. The team manages config and code; the platform manages the runtime. OpenTelemetry instrumentation is wired through a single hosting bundle and lands in Application Insights without per-call effort. Two App Service plans give independent capacity envelopes for interactive vs queued workloads. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace Mono FastCGI with Kestrel + Azure App Service Rationale: Mono FastCGI exists only because .NET Framework 4.7 has no first-party Linux runtime. There is no business reason to keep it. With .NET 8 the entire hosting story is first-party Microsoft, and the workload-shape split from 5.2.1 becomes deployable as two independent services on two independent App Service plans — something the single Mono FastCGI process cannot offer. Implementation: Both gift services publish via dotnet publish to App Service Linux (P1v3 plan each). Edge: Azure Front Door (WAF + TLS) → APIM (JWT, quota, strangler-fig route table). Mono / FastCGI are not in the deployment graph. Legacy zones outside the slice retain Mono FastCGI during coexistence per Section 11.
|
|
5.2.3 Server-Side Session-Bound Authentication
⚠️ SPECIAL CASE: This constraint is BOTH platform-driven AND business-driven
The session-bound implementation is a platform artifact of .asmx + TOpenPetraOrgSessionManager. The requirement to authenticate users and authorize per-tenant access — with role-claim gating for "can post gift batches" — is genuine business behavior. Eliminating the platform shape preserves the business intent.
| Aspect | Legacy System (.NET Framework 4.7 / Mono on Linux) | Target System (.NET 8 on Azure App Service Linux) |
|---|---|---|
| Discovery |
Source: Section 6 D-05 (medium — Mitigated by target); profile legacyIdiomInventory (TPetraPrincipal session-based auth); target-architecture handoff Identity decision (Microsoft Entra ID OIDC for new endpoints, role claim required for batch-posting endpoints)Root Cause: Authentication routes through TPetraPrincipal and the in-process TOpenPetraOrgSessionManager — server-side session state held inside the Mono FastCGI worker. .asmx services declare SessionState=true so each call resolves a session-cookie-bound principal in the worker's memory. There is no native OAuth 2.0 / OIDC / JWT support and no first-class API-token model. Horizontal scale-out requires a session-affinity layer; client-credential auth for service-to-service calls (which the worker-side Service Bus consumer needs) is not modeled at all.
|
Microsoft Entra ID (OIDC) with JWT validation enforced at Azure API Management for new endpoints. Managed identities for service-to-Azure-resource auth (Key Vault references, Blob Storage, Service Bus). Sessions become stateless tokens; horizontal scale-out is trivial; service-to-service auth is first-class — the Service Bus worker authenticates via managed identity, not a shared secret. |
| Platform Constraint (ELIMINATE) | The in-process session-state requirement forces sticky sessions on any load balancer, prevents true horizontal scale-out, and makes service-to-service authentication an afterthought. The cookie-bound principal model is entirely a .NET Framework + Mono FastCGI hosting artifact. The legacy shape simply cannot support a Service Bus worker that runs out-of-band from the user request. | JWT bearer tokens carry the principal across hops; APIM validates and propagates. App Service instances are interchangeable. Service-to-service auth uses managed identities — no shared secrets, no session affinity, no in-process state. The gift-receipting-service worker authenticates to Service Bus, Blob Storage, and Postgres via managed identity. |
| Business Requirement (PRESERVE) | Petra has real authentication and per-user authorization requirements — non-profit deployments handle donor PII and gift-financial data. The "can post gift batches" role claim is a genuine business gate (posted batches are immutable; only authorized users may close one). The act of authenticating users and gating access by role is business behavior that must continue. | Microsoft Entra ID provides authentication; per-endpoint authorization policies enforce the "can post batches" role claim. The role model and permission semantics are preserved — only the technical substrate changes. |
| Recommendation |
⚖️ HYBRID APPROACH — Adopt Microsoft Entra ID + JWT for new endpoints; retain TOpenPetraOrgSessionManager for legacy endpoints during coexistenceRationale: Modern cloud auth is the right substrate for new traffic, but flipping every legacy .asmx endpoint at once is not on the slice's path. The honest classification from Section 6 is "Mitigated, not Retired" — whole-codebase elimination is gated on every slice migrating off legacy.Implementation: Both gift services authenticate via Entra ID OIDC; APIM validates JWTs at the edge. The gift-processing-service enforces a role claim on POST endpoints that close a gift batch. Legacy /api/legacy/* routes continue to terminate at the Mono FastCGI host and use TOpenPetraOrgSessionManager sessions until those slices are themselves migrated. APIM bridges the two regimes during coexistence per Section 11.
|
|
5.2.4 NAnt Build Pipeline + In-Repo Development Tooling
| Aspect | Legacy System (.NET Framework 4.7 / Mono on Linux) | Target System (.NET 8 on Azure App Service Linux) |
|---|---|---|
| Discovery |
Source: Section 6 D-07 (low — Retired by target); Section 6.7 NAnt cross-reference (eol-project, last release 2012); profile knownEolDependencies (NAnt — effectively abandoned)Root Cause: Petra builds via NAnt — an open-source MSBuild predecessor whose last release was 2012. NAnt assemblies are referenced from inc/nanttasks/NanttasksForDevelopers.csproj by file-system HintPath (not via NuGet). Around the build, ~6,575 lines of in-repo development tooling (notably TinyWebServer, custom code-generation drivers) duplicate functionality that modern .NET tooling provides out of the box.
|
dotnet build + dotnet publish on GitHub Actions; Kestrel as the embedded development web server; Bicep or Terraform for IaC. Standard .NET 8 tooling. npm run build + Angular CLI 18 (esbuild internally) for the client. |
| Legacy Behavior | Onboarding requires installing the NAnt distribution locally (the HintPath references resolve against a developer-machine NAnt install). Build customisation lives in *.build XML files using NAnt's task vocabulary — a skill that is no longer common in the .NET community. The bespoke TinyWebServer and code-gen scripts compete with industry-standard dotnet tooling that Petra contributors have to learn alongside. |
Build is one dotnet command. CI is a standard GitHub Actions workflow. Onboarding is "clone, run dotnet build, run dotnet test." No custom assembly install steps; no NAnt task vocabulary; no in-repo helper-server. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace NAnt + in-repo tooling with dotnet CLI + GitHub ActionsRationale: NAnt has been effectively abandoned for over a decade. The bespoke development tooling exists because .NET Framework + Mono + NAnt did not provide turnkey Linux build and run experiences; modern .NET 8 ships those experiences in-box. Section 4 explicitly lists NAnt under "What's retired" for the slice. Implementation: Both gift services build with dotnet build / dotnet test / dotnet publish. CI/CD runs on GitHub Actions; per-environment Bicep parameter files provision App Service plans, Service Bus, Blob containers, and Key Vault references. The TinyWebServer and custom code-gen scripts have no analog in the new services. NAnt persists for legacy zones during coexistence; subsequent slices retire it as they migrate.
|
|
5.3 User Interface Constraints
5.3.1 jQuery + ES5 Web Client Ceiling
| Aspect | Legacy System (jQuery + ES5) | Target System (Angular 18 + TypeScript 5.4) |
|---|---|---|
| Discovery |
Source: Section 6 D-01 (high — Retired by target; the only high-severity concern Sage surfaced); profile affinityWinExamples (jQuery DOM mutation in controllers → Angular templates + reactive forms)Root Cause: The browser-side client is built on jQuery and vanilla ES5 JavaScript — no module system, no component framework, no type system, no first-class testability surface. The five gift screens (Gift Batches, Recurring Gift Batches, Motivations Setup, Bank Import, Print Annual Receipts) all share the same jQuery + Bootstrap-modal pattern with hand-rolled state management and ad-hoc $.ajax RPC calls back to TGiftTransactionWebConnector. Petra's interactive UI lives in roughly 6,682 lines of hand-written DOM-manipulation code that lacks the structural primitives (components, props, reactive state, dependency injection) which any modern frontend assumes.
|
Angular 18 SPA with five standalone components — one per gift screen — under lazy-loaded feature modules. Signals-based reactivity, the new @if/@for control flow, TypeScript 5.4 type checking, and Angular CLI 18's esbuild build pipeline. Reactive forms replace hand-rolled state management; HttpClient with typed contracts replaces ad-hoc $.ajax. Job-status polling for the queued workflows (annual receipts, SEPA export, bank import) is a typed RxJS stream against the render_job status endpoints. |
| Legacy Behavior | Each of the five gift screens reinvents its own state management. The Bank Import screen and the Print Annual Receipts screen, both of which ought to expose a job-progress UX, instead use Bootstrap-modal "loading" spinners that block the page while the synchronous request runs — a pattern dictated by the synchronous server-side pipeline (5.2.1) and the lack of a real client-side state model. UI consistency drifts because there is no shared component library; ecosystem reach narrows because libraries built for the modern era either don't fit jQuery+ES5 or require adapter shims. Browser-feature ceilings (no module system, no async/await idiom, no reactive primitives) are felt on every screen. | Five Angular 18 standalone components compose into a single SPA. Shared components (GiftBatchTable, MotivationLookup, JobStatusPanel, BankImportLineMatcher) carry consistent state-management patterns. The Bank Import and Annual Receipts screens use the JobStatusPanel against the queued-worker status endpoint — non-blocking, real-time progress, dead-letter visibility. New screens compose existing components rather than re-implementing primitives. The TypeScript compiler catches an entire class of legacy errors at build time. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace jQuery + ES5 client with Angular 18 SPA across all five gift screens Rationale: The jQuery + ES5 ceiling is the only high-severity concern Sage surfaced and it is the cleanest exit ramp in the target architecture. There is no business reason to keep jQuery for the slice; the target's Angular 18 component model serves every UI requirement the legacy client meets, with a far better dev-experience baseline. Five screens worth of consistent rebuilding gives the slice meaningful pattern-establishment for the rest of the modernization. Implementation: Each of the five gift screens is rebuilt as an Angular 18 standalone-component tree under a lazy-loaded feature module. Routing is at APIM: /api/gift/* traffic goes to the new SPA; everything else continues to the legacy jQuery client. Both surfaces coexist behind the same edge until subsequent slices migrate. Per-screen layout transformations (e.g., Bank Import's new JobStatusPanel + dead-letter retry surface) are detailed in Section 7.
|
|
5.3.2 Bootstrap 4 EOL + Browserify / popper.js Build Chain
| Aspect | Legacy System (jQuery + ES5) | Target System (Angular 18 + TypeScript 5.4) |
|---|---|---|
| Discovery |
Source: Section 6.7 client-side dependency table — bootstrap@^4.6.1 (eol-major-version, EOL January 2023), popper.js@^1.16.1 (eol-project — renamed to @popperjs/core), browserify@^16.5.2 (eol-project — community-deprecated), browserify-css@^0.15.0 (co-versioned), uglify-js@^3.16.0 (outdated — redundant under modern Angular CLI)Root Cause: The legacy client's UI framework and front-end build chain are simultaneously deprecated. Bootstrap 4 reached end of official support in January 2023. popper.js v1 was abandoned and renamed; the legacy package id is no longer maintained. Browserify is community-deprecated — modern front-end builds use webpack, Vite, or esbuild. None of these have security-patch coverage going forward; the build chain itself is on borrowed time. All five gift screens ride this same UI framework and build chain.
|
Bootstrap 5 or Angular Material (component-design phase decides) for the UI framework. Angular CLI 18 with esbuild as the production bundler; minification handled by Terser/esbuild internally; @popperjs/core consumed via Bootstrap 5 / Angular Material's positioning machinery. Single supported build chain. |
| Legacy Behavior | The team carries a build pipeline whose components stop receiving updates. Bootstrap 4 themes accumulate workarounds for issues fixed in Bootstrap 5. popper.js v1 collisions with libraries that depend on @popperjs/core require version-shim hacks. browserify + browserify-css + uglify-js is three tools doing what one modern bundler does — build configuration sprawl with no upgrade path. |
One Angular CLI command produces the production bundle. The UI framework is on the current major. Positioning, transitions, and accessibility primitives come from a single supported source. Build configuration is conventional Angular CLI; no bespoke browserify-css wrangling. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace the legacy front-end build chain wholesale Rationale: Bootstrap 4, popper.js v1, browserify, and uglify-js are all either EOL, renamed, or community-deprecated. There is no incremental upgrade story — every component needs a successor. Doing it as a single Angular CLI 18 adoption is cleaner than nibbling. Implementation: Angular CLI 18 with esbuild as the production bundler. Bootstrap 5 or Angular Material for components (final pick in component-design phase). popper.js v1 is removed entirely — positioning is handled by Bootstrap 5 / Angular Material natively. uglify-js is removed — Terser/esbuild handles minification.
|
|
5.3.3 axios 0.21.x with Documented CVEs
| Aspect | Legacy System (jQuery + ES5) | Target System (Angular 18 + TypeScript 5.4) |
|---|---|---|
| Discovery |
Source: Section 6.7 client-side dependency table — axios@^0.21.4 (known-cve)Root Cause: The legacy client uses axios 0.21.x for HTTP RPC against the .asmx server. This version line carries two known CVEs: CVE-2021-3749 (regex-DoS in trim()) and CVE-2023-45857 (XSRF token leak via cross-domain redirects). The modern axios line (1.x) addresses both, but Petra is still on 0.21 — a version pin that, taken together with the rest of the front-end build chain, exposes the client to known exploits. This is the only known-cve entry in the entire 12.7 dependency audit.
|
Angular HttpClient with HttpInterceptor for cross-cutting concerns (auth, error handling, telemetry). Typed contracts via TypeScript interfaces. No third-party HTTP library required. |
| Legacy Behavior | The team is one disclosed CVE away from a forced upgrade. Until the upgrade, the client carries a known-vulnerable HTTP library — an audit-finding waiting to happen on any security review. Patching means moving from 0.21 to 1.x with breaking changes in the API surface (config option renames, response interceptor semantics). | Angular HttpClient is the standard SPA HTTP client. Security patches arrive via Angular itself; there is no bespoke HTTP library to track separately. The CVE tracking surface for HTTP collapses into the Angular update cadence. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace axios 0.21 with Angular HttpClientRationale: The CVEs are documented and the package is non-trivial to upgrade in place (0.21 → 1.x is a breaking change). Replacing with the framework-standard HttpClient is both safer and simpler than incremental axios maintenance. No business rule depends on axios specifically.Implementation: The new gift SPA uses Angular HttpClient via HttpClientModule. JWT bearer-token attachment via HttpInterceptor. Typed request/response DTOs — e.g., GiftBatchDto, RecurringGiftBatchDto, RenderJobStatusDto. axios is removed from package.json for the slice; legacy zones retain it during coexistence per Section 11.
|
|
5.4 Data Type Constraints
5.4.1 XML-Driven Code-Generated Typed Datasets (Preserve-Schema Variant)
⚠️ SPECIAL CASE: This constraint is BOTH platform-driven AND business-driven
The petra.xml-driven code-generation pipeline is a legitimate architectural strength (Section 6.4) — it is the single source of truth for schema. The code-generation-via-typed-datasets shape is platform-driven and obsolete on .NET 8; the schema itself (the a_gift_* / a_motivation_* / a_recurring_gift_* tables, the gift-domain system of record) is preserved end-to-end — it is the system-of-record for posted financial transactions and the migration deliberately does not disturb it.
| Aspect | Legacy System (.NET Framework 4.7 / typed datasets) | Target System (.NET 8 / EF Core 8 code-first against the preserved schema) |
|---|---|---|
| Discovery |
Source: Section 6.4 strengths catalog (Single-source-of-truth schema petra.xml; Code Generation Pipeline TDataDefinitionParser, TDataDefinitionStore); target-architecture handoff "What's retired" (GiftBatchTDS typed-dataset class, petra.xml-driven code-generation pipeline for the slice) and "What's preserved" (the entire gift-domain schema end-to-end); profile legacyIdiomInventory (Generated typed datasets from XML schema)Root Cause: Petra's data layer is generated from a single canonical XML schema ( petra.xml) by the NAnt-driven code-generation pipeline. The generator emits typed-dataset classes (e.g., GiftBatchTDS) that are .NET Framework System.Data.DataSet subclasses with strongly-typed columns and rows. This is .NET Framework 1.x/2.0-era data-binding plumbing — weighty, schema-rigid, and intertwined with the typed-dataset designer that .NET Core/5+ never supported. The 8 gift-domain 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) all flow through this generator.
|
EF Core 8 code-first with EF Core Migrations — against the preserved gift-domain schema. Domain entities are POCOs mapped to the existing physical tables; no DDL changes in this wave. DbContext + IDbContextTransaction replace the typed-dataset / TDBTransaction delegate pattern. FluentValidation handles DTO validation at API boundaries. Dapper handles the high-volume donor-extract dynamic-SQL queries where EF Core's expression tree would be a poor fit. |
| Platform Constraint (ELIMINATE) | Typed datasets are .NET Framework-only — there is no .NET Core/.NET 5+/.NET 8 runtime support for the typed-dataset designer or the generated dataset surface. Adding a column requires editing petra.xml, running NAnt code-gen, recompiling the typed-dataset class, and propagating changes through every consumer. The whole TDBTransaction delegate pattern is built around the typed-dataset model. The pattern is inseparable from the .NET Framework runtime. |
EF Core 8 entities are plain C# classes mapped to the existing physical tables. The GiftBatchTDS class, the TGiftTransactionWebConnector RPC class, and the THttpConnector.CallWebConnector RPC pattern are all retired for the slice. DbContext is the unit-of-work surface; transactions are first-class via IDbContextTransaction; no code-gen pipeline runs at build time for the slice. |
| Business Constraint (PRESERVE) | The gift-domain schema is the system-of-record for posted financial transactions. It is shared with downstream finance reporting, has years of operational history, and carries strict invariants (sequential gift-batch numbering, posted-batch immutability, multi-currency three-amount columns on every gift-detail line, SEPA mandate-reference format, tax-deductible-percentage 0..100 bounds). These are not platform concerns — they are gift-domain financial rules. | Schema preserved end-to-end. EF Core 8 entities map to the same physical tables (a_gift_batch, a_gift, a_gift_detail, etc.). Domain rules are enforced as typed exception classes (BatchAlreadyPostedException, FinancialPeriodClosedException, TaxDeductibleOutOfBoundsException) above the field-level FluentValidation layer. The single-source-of-truth-for-schema property is preserved at the database level — the preserved schema is the source of truth. |
| Recommendation |
⚖️ HYBRID APPROACH — Retire typed-dataset codegen + the petra.xml generator for the slice; preserve the gift-domain schema end-to-end; map EF Core 8 entities against the preserved tablesRationale: The typed-dataset / codegen pipeline shape is a .NET Framework artifact with no .NET 8 path. The gift-domain schema, by contrast, is a genuine business asset — it is the financial system of record, shared across modules, with operational history. Preserving the schema while retiring the access-layer plumbing is the honest split. Per target-architecture caveat 5, whether petra.xml generation survives for the rest of OpenPetra during coexistence is a Section 11 decision; the slice does not run it. The schema here is the asset, not the redesign target.Implementation: Both gift services define POCOs under Gift.Domain mapped to the existing physical tables. EF Core 8 + Npgsql + EF Core Migrations (used only for the six small net-new operational tables: render_job, rendered_receipt, sepa_export_file, bank_import_upload, bank_import_line, idempotency_keys). GiftBatchTDS and TGiftTransactionWebConnector are not regenerated — they are replaced by ASP.NET Core 8 Minimal API endpoints. Legacy zones retain petra.xml + code-gen during coexistence.
|
|
5.4.2 Newtonsoft.Json 13.x as Default Serializer
| Aspect | Legacy System (.NET Framework 4.7 / typed datasets) | Target System (.NET 8 / System.Text.Json) |
|---|---|---|
| Discovery |
Source: Section 6.7 server-side dependency table (Newtonsoft.Json 13.x — outdated, replaced by target); Section 6 D-06 (low — Common Utilities coupling, mitigated by target); profile knownEolDependencies (Newtonsoft.Json — not EOL but Microsoft recommends System.Text.Json for new code)Root Cause: Petra uses Newtonsoft.Json as the default JSON serializer across the .asmx wire format (despite the SOAP envelope, the wire content is JSON), the THttpConnector.CallWebConnector RPC pattern, and the typed-dataset serialization paths. Newtonsoft is still maintained but is no longer the Microsoft-recommended default for new code; System.Text.Json shipped with .NET Core 3.0 (2019) and has been the framework default for new ASP.NET Core projects since. The behavioral differences between the two libraries (default property-naming, null handling, datetime serialization, polymorphic type resolution) require per-call review on migration — this is not a drop-in replacement.
|
System.Text.Json with explicit JsonSerializerOptions per-endpoint where Newtonsoft compatibility matters (e.g., camelCase property naming, ISO-8601 datetimes with offset, JsonStringEnumConverter for enum-as-string). Domain DTOs are explicit C# records with [JsonPropertyName] attributes where the API contract requires a specific shape. |
| Legacy Behavior | The team carries a non-default, non-built-in JSON serializer that requires explicit NuGet dependency management, has its own performance and security update cadence separate from the .NET runtime, and exposes the typical Newtonsoft serialization quirks (default reference-loop handling, default datetime parsing) that have to be reasoned about per-call. | JSON serialization is built into the runtime; no separate dependency to track. Performance is generally better; security updates land via the .NET runtime updates. Per-endpoint JsonSerializerOptions makes the behavioral choices explicit and testable. |
| Recommendation |
✅ ELIMINATE CONSTRAINT — Replace Newtonsoft.Json with built-in System.Text.JsonRationale: Newtonsoft is not EOL but it is no longer the .NET 8 default, and the gift-services API surface is net-new — there is no compatibility burden from existing Newtonsoft consumers within the slice (the legacy .asmx endpoints continue to use Newtonsoft on the legacy side). New code defaulting to the framework's built-in serializer is the lower-friction long-term choice. The behavioral-difference review (Microsoft's "Migrate from Newtonsoft.Json to System.Text.Json" guide) is a one-time upfront cost, not an ongoing one.Implementation: Both gift services use System.Text.Json throughout. JsonSerializerOptions configured per-endpoint with camelCase property naming, ISO-8601 datetime serialization, and JsonStringEnumConverter for enum-as-string (preserving the legacy wire format where the gift-domain DTOs require specific enum strings). Newtonsoft is not in either gift service's csproj; legacy zones retain it during coexistence.
|
|
5.4.3 Multi-Currency Three-Amount Numeric Precision
⚠️ SPECIAL CASE: This constraint is BOTH platform-driven AND business-driven
The legacy typed-dataset numeric column shape is platform-driven and goes away with the typed-dataset retirement (5.4.1). The three-amount-per-line multi-currency rule (transaction / base / international amounts on every gift-detail line) is genuine business behavior and must be preserved across the schema transition — the gift-domain schema is preserved precisely because these invariants matter.
| Aspect | Legacy System (.NET Framework 4.7 / typed datasets) | Target System (.NET 8 / EF Core 8 code-first against the preserved schema) |
|---|---|---|
| Discovery |
Source: Section 6 D-02 (medium — Mitigated by target); target-architecture handoff "What's preserved" (Multi-currency three-amount pattern on every gift-detail line; tax-deductible-percentage 0..100 bounds); target-architecture handoff Sage MCP usage (MultiCurrencyGiftProcessingRule confidence 0.83, MultiCurrencyAmountHandling pattern confidence 0.90, TaxDeductiblePercentageBoundsConstraint confidence 0.89) Root Cause: Each a_gift_detail row carries three amount columns — transaction-currency amount, base-currency amount, international-clearing-house amount — with their own decimal-precision constraints (typically decimal(16,2) for amounts and decimal(5,2) for tax-deductible percentages). On the legacy side, these are typed-dataset System.Decimal columns whose precision is inherited from the schema and enforced primarily by the database; the System.Data.DataSet binding layer treats them as opaque numerics. The "three amounts per line" pattern is genuine multi-currency accounting (transaction in donor's currency, base in ledger's currency, ICH for inter-ledger clearing) — it exists for ICH multi-jurisdiction reporting requirements, not because of any platform limit. The platform-driven part is the typed-dataset binding shape that handles these awkwardly; the business-driven part is the three-amount rule itself.
|
EF Core 8 entities expose the three amount columns as C# decimal properties with explicit [Precision(16, 2)] attributes (or fluent-API equivalent) on the gift-detail entity. FluentValidation enforces per-field bounds at the API boundary (e.g., TaxDeductiblePercentageRule for the 0..100 range); domain-rule classes enforce cross-field invariants (transaction-amount sign agrees with base-amount sign, currency-code valid against the ledger's allowed currencies). Domain exceptions (TaxDeductibleOutOfBoundsException) carry the typed bounds context. |
| Platform Constraint (ELIMINATE) | The typed-dataset column-binding shape (where decimal precision is enforced primarily at the database and is opaque to the dataset binding layer) is platform-driven. Adding cross-field validation requires writing it outside the dataset, in the web-connector layer, or in stored procedures — there is no first-class typed-rule surface in the dataset itself. This is the same underlying constraint as 5.4.1. | EF Core 8 entity precision is declared in code ([Precision(16, 2)]) and enforced at both the C# property level (compile-time decimal) and the database level (DDL-side decimal precision). FluentValidation + custom domain-rule classes are first-class citizens; cross-field invariants live in named C# rule classes that can be unit-tested with golden-master fixtures. |
| Business Constraint (PRESERVE) | The three-amount-per-line multi-currency pattern (transaction / base / international) and the tax-deductible-percentage 0..100 bounds are gift-domain financial rules. They exist for cross-ledger ICH accounting and multi-jurisdiction tax compliance, not because of any platform limit. Removing or simplifying these would break correctness for international gift posting and tax reporting. | Schema preserved end-to-end. The three amount columns and the tax-deductible-percentage column are mapped 1:1 to the new entities. Domain-rule classes (MultiCurrencyAmountConsistencyRule, TaxDeductibleBoundsRule) carry the business logic; FluentValidation handles the per-request boundary check; the TaxDeductibleOutOfBoundsException typed exception surfaces violations to the API caller via RFC 7807 problem responses. |
| Recommendation |
⚖️ HYBRID APPROACH — Retire the typed-dataset numeric column-binding shape; preserve the multi-currency three-amount pattern and tax-deductible bounds as typed domain rules Rationale: This is the textbook hybrid. The platform shape (typed-dataset numeric column binding with implicit precision and externalised validation) is obsolete; the business rule (three amounts per line, tax-deductible-percentage 0..100, multi-currency consistency) is genuine financial behavior that must be preserved. The "Mitigated by target" classification on D-02 specifically refers to this kind of preservation: the calculations are not rewritten, but they are wrapped in a modern infrastructure surface (typed rules, structured exceptions, FluentValidation, golden-master tests). Implementation: Gift-domain entities under Gift.Domain declare [Precision(N, M)] on amount and percentage columns matching the preserved schema. Domain-rule classes in Gift.Domain.Rules carry the multi-currency consistency rule, the tax-deductible bounds rule, and the SEPA mandate-format rule. FluentValidation in Gift.Application.Validators enforces per-request field-level checks. Golden-master tests (per Section 4 testing decision) cover batch posting and tax-deductible calculation against the preserved-schema test fixtures — this is the gating test for "the slice produces the same financial outcomes as the legacy."
|
|
Summary: Platform Affinity Decisions
| Constraint | Category | Driver | Decision | Modern Equivalent |
|---|---|---|---|---|
| .NET Framework 4.7 NuGet ceiling | Capacity | Platform (runtime ceiling) | ✅ ELIMINATE | .NET 8 LTS — ~14 deps collapse from one runtime upgrade |
| SharpZipLib 1.3.3 + PDFsharp 1.50 in-slice | Capacity | Platform (runtime-gated library ceilings) | ✅ ELIMINATE | System.IO.Compression + QuestPDF / PuppeteerSharp |
| Synchronous request-pipeline for batch workloads | Processing | Platform (.asmx + Mono FastCGI in-process) | ✅ ELIMINATE | Two services + Azure Service Bus (3 queues) + queued workers |
| Mono FastCGI hosting | Processing | Platform (no first-party Linux runtime on .NET 4.7) | ✅ ELIMINATE | Kestrel + Azure App Service Linux (P1v3, two plans) |
| Server-side session-bound auth | Processing | Platform + Business | ⚖️ HYBRID | Microsoft Entra ID + JWT (new); legacy session retained during coexistence |
| NAnt build + in-repo tooling | Processing | Platform (abandoned build system) | ✅ ELIMINATE | dotnet CLI + GitHub Actions |
| jQuery + ES5 web client (5 gift screens) | UI | Platform (framework ceiling) | ✅ ELIMINATE | Angular 18 SPA — five standalone components |
| Bootstrap 4 + browserify / popper.js v1 | UI | Platform (EOL build chain) | ✅ ELIMINATE | Angular CLI 18 (esbuild) + Bootstrap 5 / Angular Material |
| axios 0.21.x with documented CVEs | UI | Platform (vulnerable HTTP library) | ✅ ELIMINATE | Angular HttpClient + HttpInterceptor |
| XML-driven typed-dataset codegen (preserve-schema variant) | Data Type | Platform + Business | ⚖️ HYBRID | EF Core 8 code-first against the preserved gift-domain schema |
| Newtonsoft.Json 13.x default serializer | Data Type | Platform (non-default serializer) | ✅ ELIMINATE | System.Text.Json with explicit per-endpoint options |
| Multi-currency three-amount numeric precision | Data Type | Platform + Business | ⚖️ HYBRID | EF Core [Precision] + FluentValidation + typed domain-rule classes |
Summary: Of the 12 platform constraints surveyed across the four categories, 9 are pure ELIMINATE (platform-only, no business value) and 3 are HYBRID (platform shape on a real business concept — identity, schema-as-source-of-truth, multi-currency precision). No constraints in this curated subset are PRESERVE-only — the section is fundamentally about unlocks, and the genuinely-business-driven constraints are documented in Section 10 (Business Rules) rather than here.
What this means for the migration: Two strategic ceilings dominate the picture — the .NET Framework 4.7 runtime ceiling (5.1.1, 5.1.2) and the synchronous in-process request pipeline (5.2.1). Breaking the first dissolves a long tail of NuGet pins; breaking the second separates interactive gift-batch entry from the queued workloads (annual receipts, SEPA export, bank import) that monopolise threads in the legacy shape. The HYBRID entries (5.2.3 identity, 5.4.1 schema-source-of-truth, 5.4.3 multi-currency precision) are the places where the slice deliberately preserves business behavior while modernising the substrate around it — this is the shape of a gift-processing migration that ships financial correctness alongside ecosystem reach.
6. Technical Debt Analysis
The architectural concerns and strengths in 6.2–6.6 are sourced from Sage AI’s project-level architectural assessment (LLM confidence 0.85). The catalog itself is project-wide and cycle-stable; what changes between analyses is the classification of each concern against the committed target architecture. 6.5 is sourced from Sage’s anti-pattern check library for the .NET / AngularJS source profile. 6.7 is sourced from manifest-file scanning of Petra’s repository. Classifications against the target architecture trace to the target-architecture analysis (Section 4) — committed Azure App Service / .NET 8 / Angular 18 stack for the Finance — Gift Processing slice (two-service split: gift-processing-service + gift-receipting-service worker).
6.1 Methodology
This section distills Petra’s technical debt into a stakeholder-readable view across two distinct categories:
- Architectural debt (6.2–6.6) — patterns and design choices that create cost, risk, or constraint at the system level. The data source is Sage’s project-level architectural assessment, which surfaces severity-ranked Areas of Concern grounded to specific architectural elements, balanced by an explicit list of Architectural Strengths.
- Library/version debt (6.7) — specific outdated dependencies discovered by scanning Petra’s manifest files (
*.csproj,packages.config,package.json). This is the bill-of-materials view: which packages have known migration paths, which are EOL, which carry CVE history.
These are different stakeholder framings. Architectural debt informs strategy (do we replace the layer? rewrite the slice? wrap and isolate?). Library debt informs execution (what version-bumps and replacements does the migration ship?). Both classifications below are scoped against the same proposed target architecture.
Each architectural concern is classified using a four-bucket framework:
- Retired by target — the target architecture removes the cause; the debt is gone post-migration.
- Mitigated by target — the target reduces the impact (e.g., adds observability, isolates new code from a persistent legacy pattern, replaces a chunk of the affected surface) but the underlying complexity remains in legacy zones.
- Inherited — the target keeps the pattern; the debt persists. Stakeholders deserve the honest read.
- Out of scope — the concern is real but is not addressed by this engagement’s migration scope; tracked as future-investment.
Each library/version entry is classified on two axes (lifecycle status × target action) — see 6.7 for details.
What this section is not: it is not a code-quality lint report, not a comprehensive security audit, not a defect list. It is a debt assessment scoped to architectural-level patterns and dependency-level lifecycle status. The Petra source profile (dotnet-angularjs) is framed as semi-modern — the codebase is not terminally legacy, but it is decaying in identifiable, defensible ways.
Scope note for classification. The committed migration scope (Section 4) is the Finance — Gift Processing slice, decomposed into two services: gift-processing-service (synchronous interactive surface — five web screens for gift batch entry, recurring gifts, motivations, donor extracts, and federation reads) and gift-receipting-service (asynchronous queued worker handling annual-receipt rendering, SEPA Direct Debit XML export, ICH multi-currency export, and bank-statement import). Concerns and dependencies whose architectural element falls outside that slice are classified Out of scope / Outside slice: real debts that the modernized slice does not touch. This is the honest read — the slice retires what it retires, and the rest is future investment.
6.2 Severity-ranked concerns (architectural)
Seven architectural concerns surfaced, distributed as one high-severity, four medium-severity, and two low-severity. Highest severity first.
| # | Concern | Architectural element | Severity | Classification |
|---|---|---|---|---|
| D-01 | Web Client Interface relies on jQuery and vanilla JavaScript ES5, limiting modern browser capabilities and development productivity. | Presentation Layer.Web Client | high | Retired by target |
| D-02 | Finance Operations Engine implements complex multi-currency calculations and automated workflows that may create performance bottlenecks under high transaction volumes. | Application Layer.Finance Management | medium | Mitigated by target |
| D-03 | XML Report Template Definitions contain over 13% of codebase in template files, creating maintenance overhead and potential consistency issues. | Presentation Layer.Report Templates | medium | Mitigated by target |
| D-04 | Partner Management Operations coordinates extensive CRUD operations through TPartnerEditWebConnector, potentially creating transaction management complexity. | Application Layer.Partner Management | medium | Out of scope |
| D-05 | Security Authorization Framework manages authentication through TPetraPrincipal but may lack modern security patterns like OAuth or JWT tokens. | Infrastructure.Security Framework | medium | Mitigated by target |
| D-06 | Common Utilities Framework provides foundational services but may create tight coupling across modules through shared ArrayList and Utilities classes. | Cross-Cutting Concerns.Common Utilities | low | Mitigated by target |
| D-07 | Development Tools Suite includes TinyWebServer and various utilities that may duplicate functionality available in modern development environments. | Build & Deployment.Development Tools | low | Retired by target |
Classification totals: Retired by target: 2 · Mitigated by target: 4 · Inherited: 0 · Out of scope: 1.
Why the distribution shifts compared to a Sponsorship-style slice. The Gift Processing slice sits squarely on the Finance Management surface (D-02) and on the chunk of the report-template estate that drives annual receipts (D-03). A slice that did not touch Finance Management or annual-receipt rendering would honestly classify both concerns as Out of scope. This slice does touch them — partially — so the honest read is Mitigated by target: the cause is not eliminated, but a real share of it is rebuilt with modern observability, structured exceptions, and a queued-worker pattern. D-04 (Partner Management transaction-coordination) remains Out of scope because Partner identity authoring is explicitly preserved on the legacy side.
6.3 Detailed concern narratives
D-01 (high): Web Client built on jQuery + ES5 (Presentation Layer.Web Client) — Retired by target
The browser-side client is built on jQuery and vanilla ES5 JavaScript — no module system, no component framework, no type system, no first-class testability surface. This is not a lint complaint; it is an architectural ceiling. Petra’s interactive UI lives in roughly 6,682 lines of hand-written DOM-manipulation code that lacks the structural primitives (components, props, reactive state, dependency injection) which any modern frontend assumes. The practical consequences are visible in three places: developer onboarding is slow because new contributors cannot rely on framework conventions; UI consistency drifts because each screen re-implements its own state management; and ecosystem reach narrows because libraries built for the modern era either don’t fit or require adapter shims.
This is the only high-severity concern Sage surfaced and it is the one with the cleanest exit ramp in the target architecture. Section 4 commits the client stack to Angular 18 with TypeScript 5.4, standalone components, signals-based reactivity, and the new @if/@for control flow. The Gift Processing slice rebuilds five distinct interactive screens — Gift Batches, Recurring Gift Batches, Motivations Setup, Bank Import, and Print Annual Receipts — as Angular 18 standalone components with lazy-loaded feature modules. The target-architecture handoff explicitly lists the legacy jQuery + Bootstrap-modal forms for these five screens under What’s retired. For the modernized gift-processing-service the jQuery + ES5 surface is gone, not merely upgraded.
Honest qualifier: this classification scopes to the slice the migration actually delivers. Legacy zones (Partner authoring, GL accounting, Conference, Personnel, Reporting subsystem outside the gift-receipt subset) continue to use jQuery during the strangler-fig coexistence phase per Section 4 and Section 11. The route table at the Azure API Management edge sends /api/gift/* traffic to the new Angular SPA and everything else to the legacy client — both surfaces coexist behind the same edge until subsequent slices are migrated. The high-severity debt is retired for what was migrated; whole-codebase elimination is a function of how many subsequent slices ship.
D-02 (medium): Finance Operations Engine performance under high transaction volumes (Application Layer.Finance Management) — Mitigated by target
Petra’s Finance Operations Engine (~49,362 lines, 17.2% of the cataloged codebase) carries multi-currency revaluation, gift-batch posting, ICH processing, year-end closing, and budget-period workflows. Sage flags it as a potential performance bottleneck under high transaction volumes — complex calculations are computed inline in service-facade methods rather than queued or sharded, and the multi-currency path crosses the Database Abstraction Layer for every conversion lookup.
The Gift Processing slice changes this concern’s classification compared to a slice that did not touch Finance Management. Gift batch posting, the multi-currency three-amount pattern (transaction / base / international), recurring-gift posting, motivation-detail GL defaulting, and the SEPA Direct Debit / ICH export pipelines all sit inside this engine — and they are inside the slice. The target architecture preserves the calculations themselves (Section 4’s preserve-schema decision is a deliberate choice: gift-domain math is regulator-aware and golden-master tested), but it puts modern infrastructure around them. Multi-currency calculations move into an EF Core 8 / .NET 8 service with structured logging, OpenTelemetry tracing across HTTP and Service Bus, an explicit domain-exception hierarchy mapped to RFC 7807 problem responses, and golden-master tests for batch posting and tax-deductible calculation. The performance bottleneck risk is contained by moving the worst offenders — annual-receipt rendering, SEPA export, bank-statement import — off the request thread and into the gift-receipting-service worker behind three Service Bus queues with independent App Service plan scaling.
Critical honesty note: the calculations are not rewritten. The complexity Sage flagged is not eliminated — it is observed, scaffolded, and isolated from interactive latency budgets. GL posting still flows back to legacy MFinance via federated REST (the only write to legacy from the new services), so a portion of the engine’s computational surface continues to live on the legacy stack. This is the canonical case of mitigated rather than retired: the slice ships measurable improvements in observability, scalability, and failure isolation; the underlying calculation complexity persists where the legacy boundaries persist.
D-03 (medium): XML Report Template Definitions sprawl (Presentation Layer.Report Templates) — Mitigated by target
~39,992 lines (14.0% of cataloged) live in XML report templates. Sage flags this as maintenance overhead and potential consistency risk: each template re-declares its queryDetail hierarchy, formatters, and parameter bindings, and there is no schema-level enforcement that two reports with the same fact format it identically.
The Gift Processing slice does not retire the XML Report Template estate wholesale — donor extracts, gift summary reports, and the broader Reporting subsystem remain on the legacy template engine. But annual-receipt generation is a meaningful portion of the template estate, and the slice replaces it. The target architecture moves annual-receipt rendering out of the inline XML / HTML template engine and into gift-receipting-service as a queued PDF-rendering job (QuestPDF or PuppeteerSharp, with library selection deferred to Section 8), backed by a render_job table for status, a rendered_receipt provenance table with SHA-256 hash and immutable-until timestamps, and Azure Blob Storage with an immutability policy for tax-audit retention. Per-locale receipt templates (German / English / Norwegian) move from PO-file-driven in-template substitution to .NET IStringLocalizer resources plus per-locale template files in Blob.
The slice therefore retires the annual-receipt subset of the XML template sprawl — perhaps 5–10% of the ~40K-line estate, with concrete operational benefits (provenance, immutability, batch retry via dead-letter queue). The remaining ~90%+ of XML report templates — donor extracts, gift summary reports, financial statements, conference reports, partner reports — stay on the legacy engine. Honest classification: mitigated, not retired. A subsequent reporting-modernization initiative would address the rest.
D-04 (medium): Partner Management transaction-management complexity (Application Layer.Partner Management) — Out of scope
Partner Management (~28,298 lines, 9.9%) is the central coordination point for the Petra domain — Partner is the most-related aggregate (17 relationships per the project-intel handoff). Sage flags TPartnerEditWebConnector’s extensive CRUD coordination as a transaction-management complexity risk. The pattern is service-facade orchestration over a generated DAL: each web-connector method opens a transaction, performs N data-set operations, and commits, with method-scoped (not workflow-scoped) transaction boundaries that have accumulated special-case branches over time.
Section 4 explicitly excludes Partner identity authoring from this migration: gift services read donor identity (p_partner, p_family, p_person) via federated REST against legacy MPartner, but they do not refactor TPartnerEditWebConnector. The transaction-coordination complexity Sage flagged remains untouched on the legacy side. Section 4’s caveat 9 explicitly notes that this “increases the importance of the eventual MPartner migration” — this concern is the architectural footprint of that deferred work, and is honestly logged as Out of scope rather than dressed up as something the slice handles.
D-05 (medium): Security framework lacks modern auth patterns (Infrastructure.Security Framework) — Mitigated by target
Authentication routes through TPetraPrincipal and the session-based TOpenPetraOrgSessionManager — server-side session state, no native OAuth 2.0 / OIDC / JWT support, no first-class API-token model. For a single-tenant on-premise deployment this is acceptable; for cloud deployment, modern identity is table stakes.
Section 4 commits to Microsoft Entra ID (OIDC) for the new endpoints, with managed identities for service-to-Azure-resource auth and JWT validation enforced at Azure API Management. Both gift-processing-service and gift-receipting-service use the modern pattern, with a role-claim requirement enforced for batch-posting endpoints. Critically, however, Section 4 also commits to retaining TOpenPetraOrgSessionManager for legacy endpoints during the strangler-fig coexistence phase: legacy clients continue to authenticate as they always have, with APIM bridging the two regimes. This is the canonical case of mitigated rather than retired: modern auth lives where new traffic lives, but the dated session-based framework persists wherever legacy traffic still terminates. Full retirement is gated on every slice migrating off legacy — a multi-slice horizon that Section 11 owns.
D-06 (low): Common Utilities Framework coupling (Cross-Cutting Concerns.Common Utilities) — Mitigated by target
- ~8,083 lines of shared utilities (
ArrayListwrappers, string helpers, generic Utilities classes) referenced widely across legacy modules. Section 4’s gift-processing-service and gift-receipting-service use .NET 8 BCL primitives directly (List<T>, LINQ,System.Text.Json,HttpClientFactory+ Polly) rather than the legacy utility wrappers. New code does not inherit the coupling. The legacy utility framework, however, persists in the unmigrated zones during coexistence — so the cross-module coupling that Sage flagged is contained, not eliminated. Whole-codebase retirement happens only as subsequent slices migrate.
D-07 (low): Development Tools Suite redundancy (Build & Deployment.Development Tools) — Retired by target
- ~6,575 lines of in-repo development tooling (notably
TinyWebServer, custom build scripts) duplicate functionality available out-of-box in the modern stack. Section 4 commits todotnet build+dotnet publish+npm run buildon GitHub Actions, with Kestrel as the embedded web server — standard .NET 8 tooling. The target-architecture handoff explicitly lists NAnt under What’s retired. For the modernized slice, the bespoke development-tools surface is replaced by industry-standard tooling and is genuinely retired.
6.4 Architectural strengths to preserve
Sage’s assessment surfaced five architectural strengths. The target architecture should explicitly preserve the property each strength provides, even if the implementation changes.
- Single-source-of-truth schema (
petra.xml) — the master XML schema drives database DDL, generated typed-dataset ORM classes, and report-template field bindings from one canonical specification. Section 4 retires the typed-dataset generator for the gift slice in favour of EF Core 8 code-first against the preserved gift-domain schema, but explicitly defers the broader question (per Section 4 caveat 5: whetherpetra.xmlgeneration survives for the rest of OpenPetra during coexistence is a Section 11 concern). The property being preserved is “schema changes propagate to code automatically” — EF Core Migrations supplies that property within the new services’ boundary; legacy zones retain the petra.xml pipeline. - Code Generation Pipeline (
TDataDefinitionParser,TDataDefinitionStore) — the schema-to-code transformation is a real architectural asset. Section 4 chose “replace with EF Core code-first for the slice, defer the legacy-zones decision to Section 11.” The asset is preserved in the legacy zones during coexistence; the slice substitutes a different implementation of the same property. Note that for Gift Processing the schema itself is preserved end-to-end — only the code-generation step is retired, not the tables it generates against. - Multi-Database Abstraction (PostgreSQL / MySQL / SQLite via unified connection factory) — Section 4 standardizes on Azure Database for PostgreSQL Flexible Server, but the EF Core 8 + Npgsql layer preserves the abstraction property: the same DbContext can target SQLite for unit tests (via Testcontainers or in-memory), and a future migration to a different engine touches only the provider configuration. Test-environment flexibility is retained.
- Cross-Layer Validation Framework (
TVerificationResult+ specialized validators) — consistent business-rule validation across modules is a non-trivial architectural property. Section 4 commits to FluentValidation as the single field-level validation surface for the new services, layered with custom domain-rule classes for the gift-domain financial rules (period validation, posted-batch immutability, sequential gift-batch numbering, tax-deductible 0..100 bounds, SEPA mandate-reference format, Code-128 barcode character set). The implementation changes; the property (one validation surface, consistent rule enforcement) is preserved — and the gift-domain rules become first-class typed exceptions rather than verification-result strings. - Integration Test Infrastructure (NUnit-based, including
TestPartnerEditand per-module financial coverage) — a real, exercised integration-test suite is rare and valuable. Section 4 commits to xUnit + FluentAssertions + Testcontainers (with an 80% coverage target, plus golden-master tests required for batch posting and tax-deductible calculation) for the new services, and the legacy NUnit suite continues to run against the legacy code during coexistence. The safety net that makes a strangler-fig migration credible is preserved on both sides of the boundary.
6.5 Source-language anti-patterns observed
The active source profile (dotnet-angularjs, v1.1) declares seven anti-patterns to check on every .NET Framework + AngularJS migration. Each is evaluated below against Petra’s actual setup. The point of this subsection is not to issue agent-internal warnings — it is to make explicit which framing traps a stakeholder reading this report should know the analysis successfully avoided.
- “.asmx is hard SOAP” misframing. Petra’s
.asmxservices (theTGiftTransactionWebConnector-style web connectors) appear at the wire level as SOAP envelopes, but the project-intel handoff confirms the wire format is JSON-over-HTTP RPC dispatched viaTHttpConnector.CallWebConnector. The migration story is therefore RPC-method-dispatch → REST-over-HTTP, not SOAP → REST. Section 4’s “ASP.NET Core 8 Minimal APIs” commitment matches this correctly. Trap avoided. - EF Core migration without first retiring the code-generation pipeline. Petra has the
petra.xml→TDataDefinitionParser→GiftBatchTDStyped-dataset pipeline as the source of truth for the legacy DAL. If EF Core replaced the legacy DAL while the generator continued to run, the next code-gen pass would overwrite EF-managed entities. Section 4’s Caveat 5 — “petra.xmlretirement is scoped to this slice only” — correctly retires the pipeline within the gift-services’ boundary while explicitly deferring the legacy-zone question to Section 11. Importantly, the gift-domain schema is preserved end-to-end (a_gift_batch,a_gift,a_gift_detail, etc.) — only the typed-dataset generator is retired for the slice. Trap avoided. - Counting XML report templates / inline HTML templates as “UI files”. Petra has ~39,992 lines of XML report templates (D-03 above) and a substantial HTML-template surface for the legacy jQuery client. Treating these as interactive UI surface would have inflated UI-migration scope by an order of magnitude and skewed candidate-selection scoring — particularly for this slice, which carries 45 XML report templates as part of the broader reporting estate. The candidate-selection phase correctly excluded reporting templates from interactive-UI counting (the slice has five interactive screens, not 50); the receipt-template subset of the report estate is rebuilt by
gift-receipting-serviceas part of D-03’s mitigation. Trap avoided. - “No observability today” framing. The source-profile anti-pattern warns that mature .NET projects often have ELMAH / log4net / NLog / Serilog already in place, so observability is rarely net-new on this stack. Petra in particular has a
TLoggingframework inCommon.Logging(per Sage’s technology subjects) and a structured logging pattern across web connectors. Section 6 (How Sage Helped) and Section 4’s observability commitments correctly emphasize consistency (one logger across services) and correlation (trace IDs propagated automatically across HTTP and Service Bus, so a queued receipt-batch trace spans both services) rather than “observability is new” — matching the profile’s recommended framing for .NET sources. Trap avoided. - Promising “rip out .NET Framework” without checking NuGet compatibility. Petra’s
csharp/ThirdParty/packages.configcontains in-line comments documenting that several deps (Npgsql 4.1.10, SharpZipLib 1.3.3, NUnit 3.15.2) are pinned at old versions specifically because newer versions require .NET 5/6+. The .NET Framework 4.7 ceiling is therefore not just a runtime question but a transitive-dependency cascade. Section 4 right-sizes by carving two services that ship on .NET 8 alongside the legacy 4.7 stack rather than promising whole-codebase upgrade. The package-compatibility audit in 6.7 below makes this explicit. Trap avoided. - Assuming SQL Server. Petra is multi-database (PostgreSQL primary, MySQL, SQLite) per project-intel. Section 4 standardizes the modernized slice on Azure Database for PostgreSQL Flexible Server (engine preserved from primary legacy DB) rather than defaulting to Azure SQL. The preserve-schema decision is deeper than driver choice — the entire gift-domain schema (
a_gift_batch,a_gift,a_gift_detail,a_motivation_*,a_recurring_gift_*) is preserved end-to-end. Trap avoided. - “Just upgrade AngularJS to Angular” misframing. AngularJS 1.x → Angular 17+ is a complete framework rebuild, not a version bump — the digest cycle, scope inheritance, and directive compilation model do not have direct Angular peers. Petra is actually jQuery-based (not AngularJS at all per the source-profile
cautionNotes), which makes this trap inapplicable in the literal sense, but the broader anti-pattern still applies: the legacy front-end is being replaced, not upgraded. Section 4’s “Angular 18 SPA standalone components” commitment frames this as a rewrite under the strangler-fig coexistence pattern, with five Angular 18 components (one per gift screen) replacing five jQuery + Bootstrap-modal forms. Trap avoided.
6.6 Debt outside migration scope
One concern from 6.2 is classified Out of scope. It is real architectural debt that the modernization slice does not address. Stakeholders should treat it as a future-investment line item rather than a risk the current engagement retires:
- D-04 — Partner Management transaction-coordination complexity.
TPartnerEditWebConnectorremains on the legacy stack because Partner identity authoring is explicitly out of this slice (Section 4: Partner is consumed via federated read, not authored). The transaction-management complexity persists; a future MPartner migration would address it. This is the single biggest deferred-investment item the slice leaves on the table, and it’s the architectural footprint of the “Partner is the central aggregate” reality that runs through every Sage-derived insight on the project.
Total debt-by-classification distribution: 2 retired, 4 mitigated, 0 inherited, 1 out-of-scope. The absence of an “Inherited” row is meaningful: there is no concern in the catalog that the target architecture would explicitly carry forward unchanged into the new services. Concerns either get retired by the slice, mitigated by the slice’s containment of legacy patterns and partial rebuild of the affected surface, or are honestly excluded from this engagement’s scope.
6.7 Outdated dependencies (library/version debt)
This subsection is the bill-of-materials view of Petra’s dependency surface, distinct from the architectural concerns in 6.2–6.6. Manifest-file scanning surfaced the following manifests in the cataloged Petra repository:
| Manifest | Format | Parseable deps | Notes |
|---|---|---|---|
csharp/ThirdParty/packages.config | NuGet packages.config (older format) | 24 | Single central catalog for all server-side .NET dependencies. Inline comments document version-pinning rationale. |
js-client/package.json | npm package.json | 12 (in dependencies; devDependencies empty) | Front-end build manifest for the jQuery client. Version 2018.2.0. |
inc/nanttasks/NanttasksForDevelopers.csproj | MSBuild .csproj (NAnt-tooling-only) | 0 PackageReference; 4 NAnt assembly HintPath references | Helper project that compiles a NUnitConsole tool against the locally-installed NAnt distribution. References NAnt.Core, NAnt.DotNetTasks, NAnt.NUnit, NAnt.NUnit2Tasks by file-system HintPath — not via NuGet. Targets .NET Framework 4.0. Documented as a NAnt-side-channel dependency in the table below. |
inc/template/vscode/template.csproj | MSBuild .csproj template (placeholder file) | 0 | Build-time template with ${ProjectGuid}-style placeholders. Not a real manifest; excluded from dependency parsing. |
Manifests not found (scanned, returned no matches): bower.json, *.nuspec, paket.dependencies. Petra does not use Bower, does not publish itself as a NuGet package, and does not use Paket. Two further .csproj files were found but neither contributes to the dependency catalog (one is NAnt-tooling-only with HintPath references; one is a template file).
The dependency table below is the union of packages.config (24 server entries) + package.json (12 client entries) + the NAnt cross-reference (1 entry) = 37 dependencies. Each entry classifies on two axes: Lifecycle status (current / outdated / eol-major-version / eol-project / known-cve) and Target action (removed / replaced / upgraded / kept-with-risk / outside-slice). Lifecycle status is project-wide and stable across runs; target action is the column that shifts with the chosen slice.
Server-side dependencies (csharp/ThirdParty/packages.config)
| Dep | Current version | Lifecycle status | Target action | Notes |
|---|---|---|---|---|
Npgsql | 4.1.10 | eol-major-version | Replaced by target | Manifest comment: “we cannot update to 5.x because that only supports .net 5”. Npgsql 4.x is no longer maintained. Target uses Npgsql.EntityFrameworkCore.PostgreSQL (8.x line) per Section 4. Both gift services depend on this driver. |
Newtonsoft.Json | 13.0.2 | outdated | Replaced by target | Profile-known: Microsoft recommends System.Text.Json for new code (per knownEolDependencies). Target uses System.Text.Json 8.x (built-in on .NET 8). |
NUnit | 3.13.3 | outdated | Replaced by target | Manifest comment: “don’t go to 3.16.x because that requires dotnet”. Pinned by .NET Framework 4.7 ceiling. Target uses xUnit + FluentAssertions per Section 4. |
NUnit.Console | 3.15.2 | outdated | Replaced by target | Same ceiling as NUnit. Replaced by dotnet test + xUnit runner. |
NUnit.ConsoleRunner | 3.15.2 | outdated | Replaced by target | Same ceiling. Replaced by dotnet test. |
System.Buffers | 4.5.1 | outdated | Removed by target | BCL polyfill needed on .NET Framework; built-in on .NET 8. |
System.Memory | 4.5.5 | outdated | Removed by target | BCL polyfill; built-in on .NET 8. |
System.Threading.Tasks.Extensions | 4.5.4 | outdated | Removed by target | BCL polyfill; built-in on .NET 8. |
System.ValueTuple | 4.5.0 | outdated | Removed by target | BCL polyfill; built-in on .NET 8. |
System.Runtime.CompilerServices.Unsafe | 6.0.0 | outdated | Removed by target | BCL polyfill; built-in on .NET 8. |
System.Runtime | 4.3.1 | outdated | Removed by target | BCL polyfill; built-in on .NET 8. |
Microsoft.Bcl.AsyncInterfaces | 7.0.0 | current | Removed by target | Polyfill not needed on .NET 8 (interfaces are in System.Threading.Tasks directly). |
System.Diagnostics.DiagnosticSource | 7.0.0 | current | Upgraded by target | Used by OpenTelemetry instrumentation. .NET 8 ships v8.x; bumped at runtime. |
System.Text.Json | 7.0.1 | current | Upgraded by target | .NET 8 ships System.Text.Json 8.x as part of the runtime. |
System.Text.Encodings.Web | 7.0.0 | current | Upgraded by target | Co-versioned with System.Text.Json. |
MySqlConnector | 2.2.5 | outdated | Outside slice | Petra’s multi-DB abstraction supports MySQL; the gift services standardize on PostgreSQL. MySQL driver is not in the slice. |
MimeKit | 3.5.0 | outdated | Outside slice | 4.x is current. Email send/receive is not in the gift slice (no donor email-out, no inbound email parsing in this wave). |
MailKit | 3.5.0 | outdated | Outside slice | Same situation as MimeKit. |
Portable.BouncyCastle | 1.9.0 | eol-project | Outside slice | Package was renamed to BouncyCastle.Cryptography; Portable.BouncyCastle is no longer the maintained ID. Used by the email/PGP path; not in the slice. |
HtmlAgilityPack | 1.11.46 | outdated | Outside slice | HTML parsing utility; not used by the gift services. |
libsodium-net | 0.10.0 | eol-project | Outside slice | Last release 2017; the project is effectively abandoned. Modern equivalent is NSec.Cryptography or libsodium-core. Used by the legacy crypto path; not in the slice. |
SharpZipLib | 1.3.3 | eol-major-version | Replaced by target | ZIP-wrapped CAMT bank-statement archives are imported by gift-receipting-service’s bank-import worker. Manifest comment: “do not update to 1.4.x, because that requires .net 6.0”. Target replaces this with System.IO.Compression built-in on .NET 8. |
PDFsharp | 1.50.5147 | outdated | Replaced by target | Annual-receipt PDF rendering is rebuilt by gift-receipting-service. Section 4 defers final library selection (QuestPDF or PuppeteerSharp) to Section 8, but neither is PDFsharp. Legacy zones may continue to use PDFsharp 1.50 for non-receipt PDF outputs during coexistence. |
NPOI | 2.6.0 | outdated | Outside slice | Excel/Office document I/O; not used by the gift services in this wave. |
Client-side dependencies (js-client/package.json)
| Dep | Current version | Lifecycle status | Target action | Notes |
|---|---|---|---|---|
jquery | ^3.6.0 | current | Removed by target | jQuery 3.x is still maintained, so not technically EOL — but architecturally this is the centerpiece of the high-severity D-01 concern. Section 4 commits to Angular 18; jQuery is removed for the slice (five Angular 18 standalone components replace five jQuery + Bootstrap-modal forms). Legacy zones retain it during coexistence. |
bootstrap | ^4.6.1 | eol-major-version | Replaced by target | Bootstrap 4.x reached end of official support in January 2023; Bootstrap 5 is current. The Angular 18 client uses Angular Material or Bootstrap 5 — pattern decision is in Section 4 (Code-Level Decisions). |
axios | ^0.21.4 | known-cve | Replaced by target | axios 0.21.x had CVE-2021-3749 (regex DoS) and CVE-2023-45857 (XSRF token leak). Even the modern axios line (1.x) addresses both, but Petra is still on 0.21. Replaced by Angular HttpClient in the new SPA. |
i18next | ^10.6.0 | eol-major-version | Replaced by target | i18next 10.x is from 2018; current line is 23.x. Replaced by Angular i18n + ASP.NET Core IStringLocalizer per Section 4. Per-locale annual-receipt templates (German / English / Norwegian) move from PO-file substitution to .resx + per-locale templates in Blob. |
i18next-browser-languagedetector | ^2.2.4 | eol-major-version | Replaced by target | Co-versioned with i18next 10. Replaced by Angular i18n locale resolution. |
i18next-xhr-backend | ^1.5.1 | eol-project | Removed by target | Package was deprecated and replaced by i18next-http-backend (different package ID). Removed by Angular i18n. |
browserify | ^16.5.2 | eol-project | Removed by target | Browserify is community-deprecated — modern front-end builds use webpack, Vite, or esbuild. Angular CLI 18 uses esbuild by default. |
browserify-css | ^0.15.0 | eol-project | Removed by target | Co-versioned with Browserify. Removed by Angular CLI build pipeline. |
uglify-js | ^3.16.0 | outdated | Removed by target | Angular CLI’s production build minifies via Terser/esbuild internally; explicit Uglify is unneeded. |
popper.js | ^1.16.1 | eol-project | Removed by target | Package was renamed to @popperjs/core; the legacy popper.js ID is abandoned. Bootstrap 5 / Angular Material handle positioning natively. |
@fortawesome/fontawesome-free | ^5.15.4 | outdated | Replaced by target | Font Awesome 6 is current; v5 is in maintenance. Angular 18 client picks an icon-set decision (likely Material Icons or Font Awesome 6) in component-design phase. |
cypress | ^5.6.0 | outdated | Upgraded by target | Cypress 13.x is current; v5 is from 2020. The Angular client’s e2e suite uses Cypress 13.x per Section 4 (Testing client). |
Build-system (NAnt) cross-reference
| Component | Source | Lifecycle status | Target action | Notes |
|---|---|---|---|---|
NAnt (and tasks: NAnt.Core, NAnt.DotNetTasks, NAnt.NUnit, NAnt.NUnit2Tasks) | Local-FS HintPath in inc/nanttasks/NanttasksForDevelopers.csproj | eol-project | Replaced by target | NAnt’s last release was 2012 (per profile knownEolDependencies); effectively abandoned. Section 4’s “What’s retired” lists NAnt explicitly — replaced by dotnet build + GitHub Actions for the slice. |
Risk narratives for EOL-project and known-CVE entries
NAnt (eol-project, build system). NAnt’s last release was 2012; the project is community-abandoned. Petra’s build orchestration leans on it for code-generation invocation, NUnit test driving, and assembly linking. The migration path is well-trodden: dotnet build, dotnet test, and a CI pipeline (GitHub Actions per Section 4). The slice retires NAnt for both gift services. Legacy zones continue to invoke NAnt during coexistence; full retirement is gated on every slice migrating off the legacy build.
Npgsql 4.1.10 (eol-major-version, primary database driver). This is the most stakeholder-impactful single dep in the catalog. It is the database driver for the entire system, the manifest comment explicitly documents the inability to upgrade (“we cannot update to 5.x because that only supports .net 5”), and Npgsql 4.x is no longer maintained — meaning the project is one critical CVE away from a forced cross-major-version migration with no easy rollback. The target runs Npgsql 8.x via Npgsql.EntityFrameworkCore.PostgreSQL; for the gift services this concern is retired. For the unmigrated legacy zones, this is the single highest-priority library debt the engagement leaves on the table — a follow-up engagement should expedite either a full .NET 8 migration or, at minimum, a Npgsql security audit.
SharpZipLib 1.3.3 (eol-major-version, archive I/O). Slice shift vs a non-import slice: the bank-import worker in gift-receipting-service ingests ZIP-wrapped CAMT bank-statement archives, so this dep falls inside the slice. Manifest comment documents the .NET 6.0 ceiling on 1.4.x. The target replaces SharpZipLib 1.3.3 with System.IO.Compression built-in on .NET 8 for the bank-import path. Legacy zones may keep SharpZipLib 1.3.3 for any other archive-handling that is not in this slice.
PDFsharp 1.50.5147 (outdated, PDF rendering). Slice shift vs a non-receipt slice: annual-receipt PDF rendering is rebuilt by gift-receipting-service as a queued worker, so PDFsharp moves from “outside slice” into “Replaced by target.” Final replacement library is deferred to Section 8 (QuestPDF or PuppeteerSharp). PDFsharp’s 1.50 line is from 2018; the 6.x line is current. This is a meaningful upgrade in PDF compliance and a meaningful reduction in legacy-PDF-rendering CVE exposure for the receipt path.
libsodium-net 0.10.0 (eol-project, crypto). Last release 2017. Crypto code on an abandoned package is a bigger risk class than other abandoned packages — CVE patches don’t arrive even in theory. Outside this slice (the gift services do not invoke the legacy crypto path), so no migration action this engagement; flagged for a future slice.
Portable.BouncyCastle 1.9.0 (eol-project, crypto). The package ID was renamed to BouncyCastle.Cryptography; the Portable.BouncyCastle ID is no longer the maintained version. Outside slice; flagged.
i18next-xhr-backend ^1.5.1 (eol-project, i18n). Deprecated in favour of i18next-http-backend. Replaced wholesale by Angular i18n; not flagged as a migration risk.
browserify / browserify-css / popper.js (eol-project, front-end). All three are deprecated in favour of modern peers (webpack/Vite/esbuild; @popperjs/core). All three are removed by the Angular 18 build pipeline; no migration risk in the slice.
axios ^0.21.4 (known-cve, HTTP client). 0.21.x carries published CVEs (CVE-2021-3749 regex DoS; CVE-2023-45857 XSRF token leakage) that are addressed in axios 1.x. Replaced by Angular HttpClient in the new SPA — the slice does not ship with axios at any version.
Summary
Of 37 dependencies surveyed across two real manifests (24 server, 12 client; 1 NAnt cross-reference) plus two non-contributing .csproj files:
- Server-side (24 deps): 4 current, 16 outdated, 2 eol-major-version, 2 eol-project, 0 known-cve. By target action: 7 removed, 7 replaced, 3 upgraded, 0 kept-with-risk, 7 outside-slice.
- Client-side (12 deps): 1 current, 3 outdated, 3 eol-major-version, 4 eol-project, 1 known-cve. By target action: 6 removed, 5 replaced, 1 upgraded.
- NAnt cross-reference (1): eol-project; replaced.
- Total lifecycle: 5 current, 19 outdated, 5 eol-major-version, 7 eol-project, 1 known-cve.
- Total target action (37 entries): 13 removed, 13 replaced, 4 upgraded, 0 kept-with-risk, 7 outside-slice.
Slice-driven shifts in target action vs a non-receipt, non-import slice. Two server-side deps shift from “Outside slice” into the active migration column for this slice: SharpZipLib moves to “Replaced by target” because the bank-import worker is in scope, and PDFsharp moves to “Replaced by target” because annual-receipt rendering is in scope. The lifecycle column is identical to a project-wide audit (those are project-level facts) — what changes is which legacy deps the slice has the standing to retire. The other seven outside-slice server deps (MySqlConnector, MimeKit, MailKit, Portable.BouncyCastle, HtmlAgilityPack, libsodium-net, NPOI) all sit in legacy paths the gift services never invoke.
Cross-cutting observation: the .NET Framework 4.7 ceiling is a transitive cascade. Three of the server-side outdated entries (Npgsql, NUnit, SharpZipLib) carry inline manifest comments documenting that they cannot be upgraded because newer versions require .NET 5/6+. Six BCL polyfill packages (System.Buffers, System.Memory, System.ValueTuple, System.Runtime.CompilerServices.Unsafe, System.Threading.Tasks.Extensions, System.Runtime) are present only because the runtime is .NET Framework 4.7. The library-debt picture is therefore not 37 independent decisions but one strategic decision: migrate off .NET Framework 4.7 first, and many of these entries collapse automatically. Section 4’s commitment to .NET 8 for both gift services is the ceiling-break; the slice retires or absorbs the polyfill subset (the “Removed by target” column above) wholesale because .NET 8’s BCL contains every type those polyfills exist to backport.
Provenance. Manifests scanned: 4 (2 contributing + 2 non-contributing as documented above). Patterns scanned with no matches: bower.json, *.nuspec, paket.dependencies. EOL claims cross-reference the source profile’s knownEolDependencies array (NAnt, Newtonsoft.Json) plus published CVE history (axios) and project-status verification (Bootstrap 4, Browserify, Popper.js v1, i18next-xhr-backend, libsodium-net, Portable.BouncyCastle). Test-only deps (NUnit + console runners) are included because they are EOL-pinned by the runtime ceiling, not filtered out.
7. UI/UX Transformation Examples
This section demonstrates the user-interface transformation from Petra's legacy jQuery + Bootstrap-modal Gift Processing surface to a modern Angular 18 SPA backed by ASP.NET Core 8 Minimal APIs on Azure App Service. The legacy is already a web application — the user already opens a browser, types into HTML inputs, and clicks save buttons — but the workload is fragmented behaviorally across stacked Bootstrap modals, every save round-trips through a separate .asmx endpoint on TGiftTransactionWebConnector, every long-running render holds the request thread, and the operator's mental model ("statement file in → posted batch → tax receipt out") is never represented as one continuous surface. The transformation lies in composition and workload shape: three modal stacks collapse to a single Gift Batch Lifecycle sheet, and a single synchronous render form expands into Form + Job Status Panel + Manual Review + Audit Trail thanks to the new queued-worker pattern.
7.1 UI Affinity Analysis
Before examining individual screens, the table below maps the legacy UI's recurring frictions to modernization opportunities. Every row traces to actual source content discovered via Sage MCP — the five jQuery templates (GiftBatches.html, RecurringGiftBatches.html, Motivations.html, BankImport.html, PrintAnnualReceipts.html), their controller JavaScript, and TGiftTransactionWebConnector on the server.
| Legacy UI Element / Friction | Source Reference | Disposition | Modernization Notes |
|---|---|---|---|
| Stacked Bootstrap modals (modal-on-modal) | GiftBatches.html § .tpl_edit_batch, .tpl_edit_trans, .tpl_edit_trans_detail (three modal-wide dialogs in one template) |
Remove | Three modals close their parents on save and re-open. A 137-line gift batch requires opening the detail modal 137 times to set or correct each row. Replaced by a single Angular GiftBatchSheetComponent with an inline editable grid (cell-level editing, no modal). |
| Synchronous bank-statement parse on upload | BankImport.html § #import_camt_file input + import_camt_file() handler (multipart POST holds the request thread for the entire CAMT/MT940 parse) |
Modernize | A 142-line CAMT statement parse can lock the page for 20–30 seconds with no progress indication. Replaced by a multipart upload that returns 202 Accepted and enqueues a Service Bus message; the gift-receipting-service worker parses asynchronously while the SPA shows a live status banner (BR-GFT-007 banking-code classification fires inside the worker). |
| Synchronous render-on-request annual receipts | PrintAnnualReceipts.html § btnAnnualReceipts with onclick="GenerateAnnualReceipts('all' | 'print' | 'email')" |
Modernize | A 1,400-receipt run renders in-process for 12+ minutes; if the browser disconnects at minute 11 the run is lost. Replaced by a queued worker (Service Bus receipt-batch queue with sessions enabled per ledger), per-donor parallel render tasks, and immutable Blob storage with SHA-256 provenance per receipt. |
| Donor key as opaque numeric input | BankImport.html + GiftBatches.html § autocomplete_donor(this) (autocomplete exists; result is then stored as a 10-digit Int64 partner key) |
Preserve + Modernize | The legacy has donor autocomplete (kudos), but the resolved value is a bare partner key with no recent-gift preview, no fuzzy-match scoring, and no confidence indicator. Replaced by Angular Material autocomplete with recent-gift count + last-gift-date preview in the dropdown. |
| Batch-status filter as raw enum strings | GiftBatches.html § <select name="ABatchStatus"> with options "Unposted", "Posted", "Cancelled" |
Modernize | Status appears as raw enum codes in filters and per-row chips. Replaced by color-coded status pills (gray = Unposted, green = Posted, red = Cancelled). Display labels are i18n-keyed; the underlying enum is preserved on the wire. |
| Posted-batch immutability shown only as button-hide | GiftBatches.html § CSS classes .not_show_when_posted, .only_show_when_posted, .posted_readonly (12+ occurrences) |
Preserve + Modernize | Legacy enforces BR-GFT-005 by toggling button/input visibility via class. Behavior preserved (posted batches are not editable), but the modern surface adds an explicit "POSTED · locked" header chip + tooltip explaining the immutability rather than silently hiding affordances. Server-side enforcement is a typed BatchAlreadyPostedException regardless of UI state. |
| Tax-deductible percentage silent clamp | TaxDeductibility.cs:50 — Math.Max(...,0); Math.Min(...,100) with no error path |
Modernize (tightening) | Legacy silently snaps out-of-range tax-deductible percentages to [0, 100]. The modernization rejects invalid input with HTTP 400 + RFC 7807 problem-details (BR-GFT-010). Flagged for product-owner review — deliberate UX tightening, not a defect translation. |
| Hardcoded German banking-code list for gift detection | ImportFromMT940.cs:99, ImportFromCAMT.cs:193 — if-chain over codes 052/051/053/067/068/069/119/152/166/169 |
Modernize | The 10-code list is externalized to appsettings.json: GiftDetection:GermanBankingCodes so future regulatory codes can be added without redeploy (BR-GFT-007). Bank Import UI exposes the code list per ledger as a chip array on each auto-classified row. |
| Modal close-and-reopen on every save | GiftBatches.js § save_edit_trans, save_edit_trans_detail (each calls CloseModal(this) then re-renders parent) |
Remove | Every save closes the active modal, re-fetches the parent batch via THttpConnector.CallWebConnector, and re-opens. Replaced by Angular signal-based reactive state — the row re-validates client-side without a round-trip; the server save updates an in-place RxJS store. |
i18n placeholder tokens ({caption}, {forms.save}, {batchnumber}) |
All five HTML files § double-curly-brace tokens replaced at runtime by tpl.js from PO files |
Preserve | The legacy GNU Gettext message-key namespace is preserved as Angular i18n keys + ASP.NET Core IStringLocalizer. Operators on legacy locales (de, en, nb) see no string change — the runtime is replaced. Existing PO files are converted to Angular XLF and .resx during the migration. |
7.2 Gift Batches — Modal-stack Editing → Inline Editable Grid
Gift Batches is the highest-traffic gift screen and the cleanest paradigm-shift example in the slice. The legacy renders a list of batches; opening one expands a transaction list; opening a transaction opens .tpl_edit_trans over the parent; opening a detail opens .tpl_edit_trans_detail over that. Three modal levels deep. The modern equivalent is a single inline-editable grid (cell-level edit, keyboard navigation, bulk paste). Example data obeys BR-GFT-001 (sequential batch numbers), BR-GFT-002 (period-validated effective date), BR-GFT-009 (three-amount multi-currency), BR-GFT-013 (active-only motivation pickers), and BR-GFT-005 (posted batches locked).
Legacy: jQuery + Bootstrap modal stack
[1] Three-deep modal stack: batch > transaction > detail
[2] Each save closes the modal and re-fetches the parent via CallWebConnector
[3] Editing 137 detail rows = 137 modal opens
Modern: Angular 18 inline editable grid
| # | Donor | Recipient | Motivation | Amount | Tax % | Tax Ded. |
|---|---|---|---|---|---|---|
| 1 | Klaus Mueller | Field Rwanda | SPONSORSHIP | £50.00 | 100 | £50.00 |
| 2 | Maria Schmidt | Field Rwanda | editing | £75.00 | 100 | £75.00 |
| 3 | Hans Becker | Field Kenya | SPONSORSHIP | £100.00 | 100 | £100.00 |
| 4 | Mueller Family Trust | Key Ministry: Education | KEYMIN-EDUC | £250.00 | 100 | £250.00 |
| 5 | Annette Berger | Field Uganda | needs motivation | £30.00 | 100 | £30.00 |
| 6 | Werner Bastian | Field Rwanda | GENERAL | £30.00 | 100 | £30.00 |
Inline cell edit on row 2 (motivation cell open as picker). Row 5 flagged "needs motivation" by BR-GFT-014 fall-through (recipient unit type not in {AREA, FUND, FIELD, KEYMIN}). PATCH per cell to /api/gift/batches/047/details/{i}.
Platform Affinity Wins
- 137 modal opens → zero. Inline cell edits replace the modal-stack edit cycle entirely. Bulk paste from Excel becomes a multi-cell update in a single PATCH.
- Batch identity always visible. The batch header (number, status pill, period chip, totals) stays at the top while every row is being edited. Legacy hides the parent under modal backdrops.
- Status semantics. Posted state shows as a header chip + locked grid (no edit affordance) and a tooltip explaining BR-GFT-005; legacy enforces the same rule by silently hiding buttons.
- Auto-resolved motivations. BR-GFT-014's partner-class → motivation switch runs server-side on row create; the resolved value appears as a chip with hover-text "auto-resolved from partner unit type."
- Validation badges instead of silent clamps. Tax-deductible < 0 or > 100 shows an inline red badge and rejects with HTTP 400; legacy clamped silently.
- Keyboard-first. Tab/Shift-Tab between cells; Enter commits; Esc reverts. Legacy modals required mouse-driven save+close+reopen.
7.3 Recurring Gift Batches — SEPA Mandate Capture
Recurring Gift Batches drives the upstream side of the donation cycle: the recurring-gift batch generates the SEPA Direct Debit XML that the bank collects against, which is what later produces incoming bank-statement lines (the inbound side handled by Bank Import in section 7.5). The legacy form embeds SEPA mandate capture in the Edit Transaction modal (mandate reference + given-date columns added by DB upgrade Upgrade202206_202207.cs:66). The modern equivalent is a dedicated SEPA-aware recurring-gift sheet with mandate validation feedback inline. BR-GFT-015 governs the mandate-tracking semantics.
Legacy: Edit Recurring Transaction modal
[1] Mandate reference free-text, no format validation (legacy: varchar(35) only)
[2] Mandate given-date required; no UI cue if SEPA payment method but mandate fields blank
[3] Banking details dropdown bound to p_banking_details via separate RPC
Modern: Recurring-gift sheet with SEPA panel
Donor ⇒ consolidated path (BR-GFT-015)
SEPA panel inline. Mandate reference validates against donor-key + YYYYMMDD convention (typed value object). "Generate next SEPA file" enqueues to sepa-export queue.
Platform Affinity Wins
- SEPA panel is first-class. Mandate ref + given-date + IBAN visible alongside the gift, not buried mid-modal. Format check is a typed value object (not just
varchar(35)). - Receipt-frequency interaction visible. The "Annual / consolidated" hint on the right surfaces BR-GFT-015's donor-configuration interaction (
ReceiptEachGift = false+ReceiptLetterFrequency = Annual) directly — the legacy required reading test fixtures to know this existed. - Generate-next-SEPA inline. Replaces the legacy
download_sepa(this, batch)JS handler with an enqueue tosepa-export; the file is produced asynchronously and ends up in Blob. - Banking details surfaced, not hidden. The IBAN appears as monospace text rather than a dropdown indirection, with a one-click "edit at partner" action that deep-links to MPartner via federated read.
- Inline auto-save. No close-and-reopen; recurring-gift edits commit per-field via PATCH.
7.4 Motivations Setup — Catalog Editor with Active-Status Awareness
Motivations Setup is the lightest screen in the slice but a load-bearing one: every gift detail line picks a motivation, and every motivation carries a default GL account, default cost centre, and active flag (a_motivation_status_l). The legacy renders motivation groups as a collapsible list with an Edit Detail modal whose body has six checkboxes for the boolean facets (sponsorship, membership, tax-deductible, active, etc.) and two autocomplete fields for GL defaults. The modern equivalent exposes the same data in a single editable side panel that cross-references where each motivation is currently being used. BR-GFT-013 (active-status default) governs the activation flag.
Legacy: Edit Motivation Detail modal
[1] Active checkbox unchecked = motivation hidden from gift entry pickers (BR-GFT-013)
[2] No "where used" indicator — risk of deactivating a motivation in active use
[3] Worker-support flag is HTML-commented out (line 218 of source)
Modern: Catalog editor with usage cross-reference
General donations · 0500 / 4300
Field worker support · 0500 / 4300
Key Ministry: Education · 0540 / 4400
Legacy fund 2018 · 0500 / 4399
Annual membership dues · 0510 / 4100
Deactivating this motivation will hide it from new gift entry but leave existing rows untouched.
Where-used cross-reference (SELECT count(*) FROM a_gift_detail JOIN a_motivation_detail USING (group, code)) protects against accidental deactivation. The historical bug fixed by Upgrade201911_201912.cs:48 doesn't recur because EF entity defaults IsActive = true.
Platform Affinity Wins
- Where-used cross-reference. Active-row count + recurring-gift count visible inline. Legacy required querying the database manually before deactivating a motivation in production use.
- Active flag with rule citation. The "Active" checkbox shows BR-GFT-013 inline, anchoring the activation default to its rule and surfacing the historical-bug context.
- List + edit panel side-by-side. Switching motivations is a click in the left list; legacy required closing the modal to switch.
- Status pills on every row. Active/inactive at-a-glance; legacy required opening each detail to read the checkbox.
- Worker-support flag preserved but flagged. The legacy comments-out the worker-support row (line 218 of
Motivations.html— the row exists in HTML but is commented out). The modern UI surfaces it explicitly so the product owner can decide whether to re-enable rather than carrying dead HTML.
7.5 Bank Import — Synchronous Parse → Live Job-Status Banner
Bank Import is the most workload-distinct screen in the slice. The legacy uploads a CAMT/MT940/CSV file via multipart POST and synchronously parses it on the request thread — for a 142-line file, the page locks for 20–30 seconds before the matched-status grid renders. The modern equivalent returns 202 Accepted on upload, enqueues a Service Bus message to the bank-import queue, runs the parse in gift-receipting-service's BankImportModule worker, and shows a live progress banner in the SPA (BR-GFT-007 banking-code classification fires inside the worker). This is the visible face of the queued-worker pattern and the canonical demo for "the new architecture lets the operator keep working while the file processes."
Legacy: synchronous parse, page locked
[1] Multipart upload via import_camt_file() JS holds the request thread
[2] No progress event — page-level browser spinner is the only indicator
[3] Connection drop = parse aborts; no resumability
Modern: live status banner, page interactive
55 / 142 transactions classified · 87 auto-matched · ETA 8s
052 · 34 051 · 21 166 · 18 169 · 14 non-gift · 55
| # | Counterparty | Amount | Code | Status |
|---|---|---|---|---|
| 1 | Klaus Mueller | €50.00 | 052 | auto-matched |
| 2 | Maria Schmidt | €75.00 | 052 | auto-matched |
| 3 | Hans Becker | €100.00 | 051 | auto-matched |
| 4 | "H.-J. Mueller-Berger" | €30.00 | 166 | unmatched 3 matches |
| 5 | Annette Berger | €30.00 | 052 | auto-matched |
| 6 | DHL refund 042619 | €14.30 | 119 | non-gift |
gift-receipting-service / BankImportModule · Queue: bank-import
Multipart POST returns 202 Accepted; status URL polled every 2s (or SignalR push). Operator can navigate elsewhere and return; results stream into the table as the worker progresses.
Platform Affinity Wins
- Page never locks. Operator can switch tabs, file other batches, or close the browser; the worker keeps going. Status URL is bookmarkable; revisiting picks up wherever the worker is.
- Live progress. Banner + per-row streaming = visible work. Legacy spinner gave no signal until the entire parse finished.
- BR-GFT-007 visible to operator. Banking-code classification chips show counts per code; operator learns which codes drove the auto-match without reading source.
- Resumability. A failed parse goes to dead-letter queue; an admin retry surface re-enqueues the same upload-id. Legacy crashes meant re-uploading the file.
- Fuzzy match suggestions per row. Unmatched row 4 ("H.-J. Mueller-Berger") shows "3 matches" with a click-to-review menu (top suggestions via
pg_trgmGIN index). Legacy required typing donor key by hand. - Externalized banking codes. The 10 codes are config-driven (
appsettings.json: GiftDetection:GermanBankingCodes) — new codes by config-edit, not redeploy.
7.6 Print Annual Receipts — Submit Form (1:1 view)
The Print Annual Receipts form itself is the closest 1:1 translation in the slice: same fields (start date, end date, optional single-donor filter, HTML template upload, logo, signature, email subject/body, only-test checkbox), three submit modes (Download archive, Print, Email). The legacy pre-fills the date range to last calendar year via PrintAnnualReceipts.js:11 (BR-GFT-008). The modern equivalent has the same fields rendered as Angular reactive form controls, with the date-range default surfaced as an explicit hint rather than hidden in JS. The interesting divergence is what happens after Submit — covered by section 7.8 below.
Legacy: Print Annual Receipts form
[1] Date defaults set by PrintAnnualReceipts.js:11 — no UI cue
[2] All three submit buttons run synchronously; page locks for the full render
[3] "Only test" checkbox controls commit-vs-preview but no preview surface
Modern: reactive form, queued submit
Reactive form, real-time eligibility preview, BR-GFT-008 default cited inline. Submit → POST /api/gift/annual-receipts/jobs returns 202; transitions to Job Status Panel (section 7.8).
Platform Affinity Wins
- Date default explained. BR-GFT-008 surfaced as a hint strip with override invitation; legacy hid the convention in JS.
- Eligibility preview before submit. Server-computed counts (individual / consolidated / manual-review) surface the BR-GFT-004 four-condition filter and BR-GFT-015 consolidated path before the worker starts.
- Single-donor filter as type-ahead. Replaces the legacy free-text donor name with autocomplete; legacy form had autocomplete in source but no recent-gift preview.
- Template / logo / signature as managed assets. Stored in Blob; "default" + "change" links replace the legacy three Store-as-Default buttons.
- Preview-1-receipt button. Inline preview without committing the full run replaces the all-or-nothing "Only test" checkbox.
- Submit enqueues, doesn't block. Leads into the Job Status Panel that the legacy form fundamentally cannot show.
7.7 "Beyond 1:1" — Use Case 1: The Gift Batch Lifecycle Sheet (Consolidation)
While five-screen-by-five-screen translation is useful, the highest-leverage transformation in this slice is collapsing the three-page Bank Import → Gift Batches → GL Verification flow into a single Gift Batch Lifecycle sheet. The legacy fragments the operator's continuous task ("statement file in → posted batch → GL recorded") across three independently-rendered pages with their own filter states, their own grids, and their own RPC round-trip cycles. The end-to-end traceability ("the 142-line CAMT file produced the 137-line gift batch which posted to GL transaction 8943") exists in the database (composite-key joins across bank_import_line → a_gift_detail → a_journal) but the legacy UI is too modal-fragmented to render it.
The lifecycle sheet shows on a single render: the source bank-import upload at the top (provenance), the draft → posted gift batch in the middle with inline detail editing, and the federated-GL-posting result at the bottom — all bound to a status timeline running down the right edge. This view is auto-generated from the storyboard in inputs/ui-storyboards/gift-processing-end-to-end-002-draft.md via the ui-storyboard-transformation skill: each scene in the storyboard becomes a state of the unified view showing which panels are active.
Show storyboard scene sequence (Use Case 1)
## Use Case 1: Process a Gift Batch End-to-End
### Scene 1 — Bank Import upload
- Drop CAMT/MT940/CSV onto upload zone
- Live status banner: "Parsing... 142 of 142 transactions"
- BR-GFT-007 banking-code chips per matched line
### Scene 2 — Unmatched queue
- 55 unmatched rows; fuzzy-match suggestions per row
- Operator resolves 5 edge cases (Match / Split / Reject)
- 50 auto-promote into draft batch + 87 auto-matched = 137 details
### Scene 3 — Draft batch landing
- Top of unposted-batches list with BatchNumber = LastGiftBatchNumber + 1 (BR-GFT-001)
- Period chip "2026-04 open" (BR-GFT-002)
- 137 detail rows; 12 flagged "needs motivation"
### Scene 4 — Inline detail editing
- Cell-edit motivation, tax %, recipient
- BR-GFT-014 partner-class auto-resolve fires server-side
- BR-GFT-003 recalculation cascade on % change
- BR-GFT-010 out-of-range reject (deliberate tightening)
### Scene 5 — Post Batch
- Validate (period, motivations active, no zero-amount)
- BR-GFT-002 re-fires; BR-GFT-005 immutability armed; BR-GFT-013 active check
- Federated POST to legacy MFinance for GL transaction
- Toast: "Batch GBP-2026-047 posted, GL Txn 8943"
### Scene 6 — GL receipt and lifecycle close
- Batch row greys out; no edit affordance (BR-GFT-005)
- Side panel: GL Txn 8943 with 8 journal lines across 3 cost centres
- Receipts now eligible for next annual run (BR-GFT-004 prep)
Gift Batch Lifecycle Sheet — Unified View (Scene 5 active: Post Batch)
The unified view shows every panel that the legacy splits across three pages: source upload provenance, batch + inline editable details, and federated GL posting result. Below, Scene 5 (Post Batch) is the active state — the validation panel is highlighted, the post button is armed, and the timeline shows progress through "drafted → reviewed" with "posted" pending.
POST /api/legacy/gl/transactions| # | Donor | Recipient | Motivation | Amount | Tax % |
|---|---|---|---|---|---|
| 1 | Klaus Mueller | Field Rwanda | SPONSORSHIP | £50.00 | 100 |
| 2 | Maria Schmidt | Field Rwanda | GENERAL | £75.00 | 100 |
| 3 | Hans Becker | Field Kenya | SPONSORSHIP | £100.00 | 100 |
| 4 | Mueller Trust | Key Min: Education | KEYMIN-EDUC | £250.00 | 100 |
| 5 | Annette Berger | Field Uganda | SPONSORSHIP | £30.00 | 100 |
| 6 | Werner Bastian | Field Rwanda | GENERAL | £30.00 | 100 |
What becomes possible only post-migration
- End-to-end traceability in one render. The 142-line CAMT → 137-line batch → 8-line GL journal across 3 cost centres. The legacy database supports the join, but the legacy UI never assembles it.
- No request-thread hostage. Bank-statement parse and GL post are queued workers. A 5-minute parse no longer locks the page; a slow GL write no longer leaves a half-posted batch.
- Saga-protected cross-boundary write. The federated GL post is a saga step with outbox and retry. Failure rolls back the batch-status flip. Legacy: GL write was inline and best-effort; failure required DB-admin recovery.
- Live status timeline. Operator sees lifecycle progression (uploaded → parsed → classified → drafted → reviewed → posted → GL recorded) without leaving the sheet.
- Inline detail editing replaces 137 modal opens. The single biggest usability improvement in the slice.
7.8 "Beyond 1:1" — Use Case 2: The Annual Receipt Run (Expansion, Not Consolidation)
Use Case 2 is the inverse of the standard Beyond-1:1 pattern. The legacy is a single form that synchronously produces a multipart-PDF download. There are no fragments to consolidate. What changes is that the queued-worker pattern unlocks UX surfaces the synchronous-render legacy cannot afford: a Job Status Panel (live progress per donor), a Manual Review side panel (cross-jurisdiction edge cases), and an Audit Trail screen (per-receipt provenance with SHA-256 + immutable Blob path). The form stays roughly the same; what's new is everything that happens after Submit.
The point worth flagging for the report's narrative: "Beyond 1:1" is not always consolidation. The slice's two use cases happen to demonstrate both directions — Use Case 1 collapses three pages into one (consolidation), Use Case 2 expands a single form into Form + Status + Review + Audit (expansion). The architectural shape that drives the difference is whether the legacy fragmented the workflow across pages (consolidate) or hid the workload behind a synchronous render (expand). Queued workers are the canonical case where modernization adds UI rather than removing it.
Job Status Panel — the visible face of the queued worker
Below: the post-Submit experience for the 1,431-donor annual run. The form has been replaced by a live status panel; the worker is mid-run, rendering per-donor PDFs in parallel and writing each to immutable Blob storage with SHA-256 provenance. The legacy screen at this point in the workflow was a frozen browser tab with a spinner.
+ Posted-only (BR-GFT-005)
+ Partner double-join (BR-GFT-016)
gift-receipting-service / AnnualReceiptModuleQueue:
receipt-batch (sessions ON)
rendered_receipt table)| Donor | Receipt | Amount | SHA-256 | Blob path | Rendered |
|---|---|---|---|---|---|
| Klaus Mueller | R-2025-000562 | €600.00 | a3f9…c81e | receipts/43/2025/43005400/….pdf | 13:48:22 |
| Maria Schmidt | R-2025-000561 | €900.00 | f02c…7b1a | receipts/43/2025/43021708/….pdf | 13:48:20 |
| Hans Becker | R-2025-000560 | €1,200.00 | 9d11…a04f | receipts/43/2025/43055129/….pdf | 13:48:18 |
What becomes possible only post-migration (UX surfaces unlocked by queued worker)
- Live progress per donor. Operator sees the run advance — not just a spinner. Switch tabs and come back; the panel resumes from where the worker is.
- Manual review queue. Cross-jurisdiction edge cases (country-specific preambles, unsigned-off templates, dubious addresses) surface as a side panel rather than being silently dropped or rendered with default templates. Each gets a "Render anyway / Skip / Send to PO" decision — recorded in
render_job.review_actions. - Per-receipt audit trail. SHA-256 + immutable Blob path + rendered-at + rendered-by per receipt — legacy had transient temp-file PDFs and no provenance row. Re-prints serve the same immutable PDF rather than re-rendering.
- Resumable runs. A failed render goes to the dead-letter queue; admin retries the specific donor without restarting the entire batch.
- Per-donor re-issue from audit trail. "Re-issue James Wilson's 2024 receipt" is a single audit-row drill-in → click. Legacy required running the entire annual flow filtered to one donor.
- Hash-based deduplication. Same donor + same date range + no underlying gift changes → SHA-256 match → integrity check is free. Legacy had no notion of receipt identity beyond "the file the operator downloaded."
- Cross-year audit query. "Show me every receipt this donor has received since 2022" is a single query against
rendered_receipt. Legacy: search filesystem temp directories.
7.9 Anti-Patterns Avoided in This Section
- No green-screen mockups. Petra is a web SPA — the legacy column renders the actual jQuery + Bootstrap modal stack as it appears in source, with the parent page visible-but-dimmed behind the modal backdrop. There are no fictional 80x24 terminals or function-key bars.
- No fictional fields. Every form field in the legacy column maps to a real
name="..."attribute in the source HTML —a_batch_number_i,a_batch_status_c,a_motivation_detail_code_c,a_sepa_mandate_reference_c,AStartDate,AEndDate, etc. Modal classes (.tpl_edit_batch,.tpl_edit_trans,.tpl_edit_trans_detail,.tpl_edit_motivation) match the sourcedivstructure. - No fictional BR-XXXX codes inside legacy code. BR-GFT-001..016 references appear only in analysis prose, "active rules" footers, and modernization-notes columns — never inside legacy modal markup. The behavioral-rules catalog in Section 9 is the source of truth.
- Modern column matches legacy data density. Where the legacy shows multiple rows (137 detail lines, 1,431 donors, 142 transactions), the modern column shows the same row volume in modern form — not a single summary card. The Bank Import streaming table renders 6 rows + "49 more"; the Gift Batch grid renders 6 rows + "131 more"; the audit-trail preview renders 3 rows.
- No COBOL or green-screen comparative framing. The legacy stack is .NET / Mono / FastCGI / jQuery / Bootstrap; the modernization is .NET 8 / Angular 18. The report stands on its own without comparison to other migration types.
- Mockup data obeys the cited rules. Every example value was derived to be consistent with the BR catalog — tax % stays in [0, 100] (BR-GFT-010), batch numbers are sequential (BR-GFT-001), period chip says "open" (BR-GFT-002), motivations resolved by partner unit type (BR-GFT-014), receipt amount totals match the per-donor gift history aggregation (BR-GFT-009).
- Modern column is Angular 18, not React. Per the engagement’s committed target stack and the .NET / AngularJS source profile; React would be the wrong stack for this slice.
- "Beyond 1:1" is not always consolidation. Use Case 1 collapses three pages; Use Case 2 expands one form into four surfaces. The architectural driver (queued worker + immutable artefact) determines direction.
Continue to Section 8: Code Translation Examples for the .NET 8 / Angular 18 framework-pattern translations (the inline-grid + queued-worker + saga shapes shown here as UI map to HttpClient, Azure.Messaging.ServiceBus, FluentValidation, Angular reactive forms, and OpenTelemetry on the code side).
8. Code Translation Examples
This section demonstrates the translation of Petra's framework-level idioms in the Gift Processing slice from .NET Framework 4.7 / ASP.NET Web Services (.asmx) / jQuery to ASP.NET Core 8 Minimal APIs / EF Core 8 / Azure Service Bus / Angular 18. The examples focus on cross-cutting patterns — how RPC endpoints, transactions, long-running render jobs, and verification-result error handling are reshaped — rather than business logic translations, which appear in Section 9 (Business Rules Analysis) alongside their formal Given-When-Then specifications and behavioral-fidelity classifications. BR-GFT-XXX references in this section are cross-references; the rules themselves are documented in Section 9.
CreateAnnualGiftReceipts entry point currently runs the full PDF generation inside the request handler, and modernizing it to a queued-worker pattern is the load-bearing shift that justifies the two-service split documented in Section 4.
8.1 ASMX Web Method → ASP.NET Core 8 Minimal API Endpoint
Petra exposes server-side gift operations via static methods on TGiftTransactionWebConnector, hosted on a .asmx endpoint with [RequireModulePermission] attributes that drive a custom session-based authorisation layer (TOpenPetraOrgSessionManager). The wire format is JSON-RPC over a SOAP envelope shape (per the source profile's anti-pattern note: do NOT describe .asmx as 'hard SOAP' if the wire format is actually JSON RPC). The modern equivalent is an ASP.NET Core 8 Minimal API endpoint with FluentValidation on the request DTO, OIDC bearer-token authorisation, OpenAPI metadata, and a structured Serilog event.
Legacy: .NET Framework 4.7 / .asmx
// csharp/ICT/Petra/Server/lib/MFinance/Gift/
// Gift.Transactions.cs lines 71-115 (CreateAGiftBatch)
namespace Ict.Petra.Server.MFinance.Gift.WebConnectors
{
public partial class TGiftTransactionWebConnector
{
[RequireModulePermission("FINANCE-1")]
public static GiftBatchTDS CreateAGiftBatch(
Int32 ALedgerNumber,
DateTime ADateEffective,
string ABatchDescription,
TDataBase ADataBase = null)
{
if (ALedgerNumber <= 0)
{
throw new
EFinanceSystemInvalidLedgerNumberException(
"Ledger number must be > 0",
ALedgerNumber);
}
GiftBatchTDS MainDS = new GiftBatchTDS();
TDBTransaction Transaction =
new TDBTransaction();
TDataBase db = DBAccess.Connect(
"CreateAGiftBatch", ADataBase);
bool SubmissionOK = false;
db.WriteTransaction(
ref Transaction,
ref SubmissionOK,
delegate
{
ALedgerTable ledgerTable =
ALedgerAccess.LoadByPrimaryKey(
ALedgerNumber, Transaction);
// ... assign batch number,
// validate period, populate
// MainDS, submit changes
SubmissionOK = true;
});
return MainDS;
}
}
}
Source: Gift.Transactions.cs lines 71–115
Target: ASP.NET Core 8 Minimal API
// Endpoints/GiftBatchEndpoints.cs
public static class GiftBatchEndpoints
{
public static RouteGroupBuilder
MapGiftBatches(this RouteGroupBuilder g)
{
g.MapPost("/api/gift/batches", async (
CreateGiftBatchRequest req,
IValidator<CreateGiftBatchRequest> validator,
IGiftBatchService svc,
CancellationToken ct) =>
{
var result = await
validator.ValidateAsync(req, ct);
if (!result.IsValid)
return Results.ValidationProblem(
result.ToDictionary());
var batch = await
svc.CreateAsync(req, ct);
return Results.Created(
$"/api/gift/batches/{batch.LedgerNumber}/{batch.BatchNumber}",
batch);
})
.RequireAuthorization("GiftEntry")
.WithName("CreateGiftBatch")
.Produces<GiftBatchResponse>(201)
.ProducesValidationProblem()
.WithOpenApi();
return g;
}
}
// Validation/CreateGiftBatchValidator.cs
public sealed class CreateGiftBatchValidator
: AbstractValidator<CreateGiftBatchRequest>
{
public CreateGiftBatchValidator()
{
RuleFor(x => x.LedgerNumber)
.GreaterThan(0)
.WithMessage("Ledger number must be > 0");
RuleFor(x => x.DateEffective).NotEmpty();
RuleFor(x => x.BatchDescription)
.NotEmpty().MaximumLength(80);
}
}
Target: framework-pattern translation (BR-GFT-001 sequential numbering and BR-GFT-002 period validation enforced in IGiftBatchService.CreateAsync — see Section 9).
Translation Notes
RPC verb-name → HTTP resource verb. TGiftTransactionWebConnector_CreateAGiftBatch over .asmx becomes POST /api/gift/batches. The verb-suffix idiom collapses into HTTP+resource path.
Static method → injected service. public static with hidden global DBAccess state becomes a constructor-injected IGiftBatchService. Concurrency safety, transaction lifetime, and testability all improve.
Custom session perm → OIDC role claim. [RequireModulePermission("FINANCE-1")] on the static method becomes .RequireAuthorization("GiftEntry"), an ASP.NET Core policy that maps Microsoft Entra ID role claims to "can submit a gift batch."
Inline arg-checks → FluentValidation. The hand-rolled if (ALedgerNumber <= 0) throw ... guards are lifted into a typed CreateGiftBatchValidator; failures auto-render as RFC 7807 ValidationProblemDetails (HTTP 400) rather than throwing inside the handler.
OpenAPI emerges automatically. .WithOpenApi() + .Produces<T>() generates the spec at startup; the legacy .asmx WSDL is replaced by /swagger/v1/swagger.json.
Cross-reference: the inside of IGiftBatchService.CreateAsync implements BR-GFT-001 (sequential numbering) and BR-GFT-002 (period validation). Their Given-When-Then specifications and target validators live in Section 9.
8.2 TDBTransaction Delegate → DbContext + IDbContextTransaction
Petra wraps every gift-batch read or write in a TDBTransaction passed by reference into a delegate, with a separate SubmissionOK ref-bool that the delegate sets to signal commit. This is the canonical Mono-era .NET 4.7 transactional idiom (and the source profile's legacyIdiomInventory singles it out by name: DBAccess.GDBAccessObj.GetNewOrExistingDelegate). The modern equivalent is EF Core 8 with a request-scoped DbContext and an explicit IDbContextTransaction only when an operation crosses aggregate boundaries (the gift-batch + ledger-counter increment in BR-GFT-001 is exactly such a case).
Legacy: TDBTransaction delegate + SubmissionOK ref
// Gift.Transactions.cs lines 100-115 (excerpt)
// (the inner WriteTransaction body for
// CreateAGiftBatch — sequential
// numbering + period fit)
TDBTransaction Transaction =
new TDBTransaction();
TDataBase db = DBAccess.Connect(
"CreateAGiftBatch", ADataBase);
bool SubmissionOK = false;
db.WriteTransaction(
ref Transaction,
ref SubmissionOK,
delegate
{
ALedgerTable ledgerTable =
ALedgerAccess.LoadByPrimaryKey(
ALedgerNumber, Transaction);
// BR-GFT-001 sequential numbering:
// allocate next batch number from
// the ledger row counter
AGiftBatchRow NewRow =
MainDS.AGiftBatch.NewRowTyped();
NewRow.LedgerNumber = ALedgerNumber;
NewRow.BatchNumber =
++ledgerTable[0].LastGiftBatchNumber;
NewRow.BatchDescription =
ABatchDescription;
NewRow.BatchStatus =
MFinanceConstants.BATCH_UNPOSTED;
// BR-GFT-002 period validation
TFinancialYear
.GetLedgerDatePostingPeriod(
ALedgerNumber, ref ADateEffective,
out int BatchYear,
out int BatchPeriod,
Transaction, true);
NewRow.BatchYear = BatchYear;
NewRow.BatchPeriod = BatchPeriod;
MainDS.AGiftBatch.Rows.Add(NewRow);
AGiftBatchAccess.SubmitChanges(
MainDS.AGiftBatch, Transaction);
ALedgerAccess.SubmitChanges(
ledgerTable, Transaction);
SubmissionOK = true;
});
Pattern: DBAccess.GDBAccessObj.GetNewOrExistingDelegate via WriteTransaction — per source profile legacyIdiomInventory
Target: EF Core 8 + IDbContextTransaction
// Services/GiftBatchService.cs (excerpt)
public sealed class GiftBatchService(
GiftDbContext db,
IFinancialPeriodValidator periodValidator,
ILogger<GiftBatchService> log)
: IGiftBatchService
{
public async Task<GiftBatchResponse>
CreateAsync(
CreateGiftBatchRequest req,
CancellationToken ct)
{
await using var tx = await
db.Database.BeginTransactionAsync(
IsolationLevel.Serializable, ct);
// BR-GFT-001 sequential numbering
var ledger = await db.Ledgers
.SingleAsync(l =>
l.LedgerNumber == req.LedgerNumber, ct);
var nextNumber =
++ledger.LastGiftBatchNumber;
// BR-GFT-002 period validation
var period = await
periodValidator.ResolveAsync(
req.LedgerNumber,
req.DateEffective,
forceFit: true, ct);
var batch = new GiftBatch
{
LedgerNumber = req.LedgerNumber,
BatchNumber = nextNumber,
BatchDescription = req.BatchDescription,
BatchStatus = BatchStatus.Unposted,
DateEffective = period.EffectiveDate,
BatchYear = period.Year,
BatchPeriod = period.Period
};
db.GiftBatches.Add(batch);
await db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
log.LogInformation(
"gift.batch_created " +
"ledger={Ledger} batch={Batch}",
batch.LedgerNumber, batch.BatchNumber);
return
GiftBatchResponse.FromEntity(batch);
}
}
Target: IDbContextTransaction with Serializable isolation around the ledger-row + batch-row pair (preserves the legacy concurrency guarantee).
Translation Notes
Delegate → await using pattern. db.WriteTransaction(ref t, ref SubmissionOK, delegate { ... }) becomes await using var tx = await db.Database.BeginTransactionAsync(...);. The two ref-parameters disappear because EF Core scopes the transaction implicitly via DbContext and exit-from-using auto-disposes (rolling back if no commit).
Static SubmitChanges calls → tracked entities. AGiftBatchAccess.SubmitChanges(MainDS.AGiftBatch, Transaction) + ALedgerAccess.SubmitChanges(ledgerTable, Transaction) collapse into a single await db.SaveChangesAsync(ct) — EF Core's change-tracker batches both updates into one round-trip.
Concurrency level preserved. The legacy code relied on Postgres row-locking inside WriteTransaction; the modern code asks for IsolationLevel.Serializable explicitly, so the BR-GFT-001 contract ("concurrent batch-creation attempts on the same ledger serialize") is preserved exactly.
Cancellation tokens. Petra's TDBTransaction has no cancellation support; ASP.NET Core's CancellationToken ct propagates from the request through every async call, so a client-disconnect or timeout properly rolls the transaction back.
Period validation hoisted to a domain service. TFinancialYear.GetLedgerDatePostingPeriod(...) with its out-parameter shape becomes IFinancialPeriodValidator.ResolveAsync(...) returning a typed PeriodResolution. The force-fit flag survives as forceFit: true; reject-with-409 (BR-GFT-002) is the path when forceFit is false.
Cross-reference: BR-GFT-001 (sequential numbering, 0.95 confidence) and BR-GFT-002 (period validation, 0.90 confidence) are implemented inside this method. Their Given-When-Then specifications and golden-master test specs are in Section 9.
8.3 Synchronous Render → Azure Service Bus Queued Worker ARCHITECTURAL HEADLINE
Petra's CreateAnnualGiftReceipts entry point on TReceiptingWebConnector renders every donor's annual receipt PDF synchronously inside the request handler — load donor list, fetch each donor's gift detail, run the HTML template, embed the logo and signature images, generate the PDF binary, and only then return. For a year-end run with thousands of donors this can take many minutes; the legacy UI shows a page-locked spinner and is at the mercy of any HTTP/IIS/Mono FastCGI timeout. The modern equivalent splits the boundary in two: the API endpoint persists a render_job row and publishes a RenderAnnualReceiptJob message to Azure Service Bus; the gift-receipting-service background worker (an IHostedService consuming the receipt-batch queue with sessions enabled for per-ledger ordering) does the actual rendering, writes the PDF to Azure Blob Storage, and updates the render_job status. This is the load-bearing architectural shift that justifies the two-service split documented in Section 4.
Legacy: synchronous render in request handler
// csharp/ICT/Petra/Server/lib/MFinance/Gift/
// Gift.Receipting.cs lines 75-141 (entry)
public class TReceiptingWebConnector
{
[RequireModulePermission("FINANCE-1")]
public static bool
CreateAnnualGiftReceipts(
Int32 ALedgerNumber,
string AFrequency,
DateTime AStartDate,
DateTime AEndDate,
string AHTMLTemplate,
byte[] ALogoImage,
string ALogoFilename,
byte[] ASignatureImage,
string ASignatureFilename,
string ALanguage,
string AEmailSubject,
string AEmailBody,
string AEmailFrom,
string AEmailFromName,
string AEmailFilename,
out string APDFReceipt,
out string AHTMLReceipt,
out TVerificationResultCollection AVerification,
bool ADeceasedFirst = false,
string AExtract = null,
Int64 ADonorKey = 0,
string AAction = "all",
bool AOnlyTest = true)
{
APDFReceipt = string.Empty;
AHTMLReceipt = string.Empty;
AVerification =
new TVerificationResultCollection();
Catalog.Init(ALanguage, ALanguage);
TDBTransaction Transaction =
new TDBTransaction();
TDataBase db = DBAccess.Connect(
"AnnualGiftReceipts");
db.ReadTransaction(
ref Transaction,
delegate
{
// 1. Load donor key set (extract
// or full ledger sweep)
// 2. For EACH donor: query gifts,
// apply BR-GFT-004 4-condition
// receipt eligibility filter
// 3. Render HTML → PDF inside
// the request thread
// 4. (Optional) email per donor
// All synchronous — can take
// many minutes for year-end runs
});
return true;
}
}
Source: Gift.Receipting.cs lines 75–141. The full method is ~1,500 lines; the entire critical path runs inside the .asmx request handler.
Target: API publish → Service Bus → worker
// gift-processing-service:
// Endpoints/AnnualReceiptEndpoints.cs
g.MapPost("/api/gift/receipts/annual", async (
AnnualReceiptRunRequest req,
GiftDbContext db,
ServiceBusClient sb,
ILogger<Program> log,
CancellationToken ct) =>
{
var job = new RenderJob
{
JobId = Guid.NewGuid(),
Kind = RenderJobKind.AnnualReceipt,
LedgerNumber = req.LedgerNumber,
Status = RenderJobStatus.Queued,
RequestedAt = DateTimeOffset.UtcNow,
RequestedBy = req.UserId
};
db.RenderJobs.Add(job);
await db.SaveChangesAsync(ct);
var sender =
sb.CreateSender("receipt-batch");
var message = new ServiceBusMessage(
BinaryData.FromObjectAsJson(
new RenderAnnualReceiptJob(
job.JobId, req)))
{
SessionId =
$"ledger-{req.LedgerNumber}",
ContentType = "application/json",
MessageId = job.JobId.ToString()
};
await sender.SendMessageAsync(message, ct);
log.LogInformation(
"gift.receipt.job_queued " +
"job_id={JobId} ledger={Ledger}",
job.JobId, req.LedgerNumber);
return Results.Accepted(
$"/api/gift/receipts/jobs/{job.JobId}",
new { jobId = job.JobId });
})
.RequireAuthorization("GiftReceipts")
.Produces(202);
// gift-receipting-service:
// Workers/AnnualReceiptWorker.cs
public sealed class AnnualReceiptWorker(
ServiceBusClient sb,
IServiceProvider sp,
ILogger<AnnualReceiptWorker> log)
: BackgroundService
{
protected override async Task
ExecuteAsync(CancellationToken ct)
{
await using var processor =
sb.CreateSessionProcessor(
"receipt-batch",
new ServiceBusSessionProcessorOptions
{
MaxConcurrentSessions = 4,
AutoCompleteMessages = false
});
processor.ProcessMessageAsync += async args =>
{
var msg = args.Message
.Body.ToObjectFromJson<RenderAnnualReceiptJob>();
await using var scope =
sp.CreateAsyncScope();
var renderer = scope.ServiceProvider
.GetRequiredService<IAnnualReceiptRenderer>();
try
{
await renderer.RenderAsync(
msg, args.CancellationToken);
await args.CompleteMessageAsync(
args.Message);
}
catch (Exception ex)
{
log.LogError(ex,
"gift.receipt.job_failed " +
"job_id={JobId}", msg.JobId);
await args.AbandonMessageAsync(
args.Message); // retry / DLQ
}
};
await processor.StartProcessingAsync(ct);
await Task.Delay(Timeout.Infinite, ct);
}
}
Two services, one queue. The API returns 202 Accepted immediately; the worker renders the PDFs and writes them to the immutability-policy receipts/ blob container.
Translation Notes
Synchronous render → queued worker. The 26-parameter signature (with three out-parameters) collapses to a small AnnualReceiptRunRequest DTO that's persisted as a render_job row and forwarded as a Service Bus message. The API returns 202 Accepted with a job-status URL. Client polls or subscribes via SignalR for completion.
Per-ledger ordering preserved. Service Bus SessionId = "ledger-{n}" with MaxConcurrentSessions = 4 means receipt jobs for the same ledger run in arrival order, but different ledgers run in parallel. This matches the legacy ledger-scoped invariants without limiting throughput.
Failure mode visible, not invisible. Legacy returned bool + TVerificationResultCollection; a partial failure mid-render had no audit trail. Modern render_job rows transition Queued → Running → (Succeeded | Failed); AbandonMessageAsync retries up to N times, then dead-letters. The dead-letter queue feeds the admin retry surface (see Section 7.8 Job Status Panel).
Templates moved to Blob. The legacy method takes the HTML template, logo bytes, and signature bytes as method parameters. The worker fetches them from templates/{ledger_number}/annual-receipt-{language}.html and the corresponding logo/signature blobs — the API request just passes the language code.
Immutability and provenance. Each rendered PDF is written under receipts/{ledger}/{year}/{donor}/{receipt_id}.pdf with an Azure Blob immutability policy preventing overwrite or deletion within the tax-audit retention window. A rendered_receipt table row captures the SHA-256 hash, content-length, and immutable-until timestamp.
Cross-reference: the receipt-eligibility filter (BR-GFT-004 four-condition: batch.status='Posted' AND gift.print_receipt_l=1 AND motivation_detail.receipt_l=1 AND gift_detail.modified_detail_l=0) lives in IAnnualReceiptRenderer.RenderAsync and is what ensures only receipt-eligible gifts hit the PDF; the queued message simply carries the date range and donor scope. BR-GFT-005 (posted-batch immutability) is what makes a queued render job's input set safe — the gifts being rendered cannot mutate while the job runs. Both rules' Given-When-Then specifications are in Section 9.
8.4 TVerificationResultCollection → ASP.NET Core Middleware + RFC 7807 ProblemDetails
Petra's gift-validation surface (TFinanceValidation_Gift.ValidateGiftBatchManual and its peers) signals errors by populating a TVerificationResultCollection reference parameter in-place; callers inspect HasCriticalErrors and unwind. This couples error reporting to the call-site rather than the protocol boundary, and it loses HTTP semantics entirely — every failure is HTTP 200 with a JSON-encoded verification collection. The modern equivalent is a typed exception hierarchy (GiftServiceError base with subclasses BatchAlreadyPostedException, FinancialPeriodClosedException, etc.) thrown from the domain service and caught by an app.UseExceptionHandler middleware that emits application/problem+json per RFC 7807, with the original verification context surfaced in extensions.
Legacy: TVerificationResultCollection ref-param
// csharp/ICT/Petra/Server/lib/MFinance/
// validation/Gift.Validation.cs lines 65-130
namespace Ict.Petra.Server.MFinance.Validation
{
public static partial class
TFinanceValidation_Gift
{
public static bool
ValidateGiftBatchManual(
object AContext,
AGiftBatchRow ARow,
ref TVerificationResultCollection
AVerificationResultCollection,
AAccountTable AAccountTableRef = null,
/* ...8 more optional refs... */
bool AValidateAccountCostCentre = false)
{
DataColumn ValidationColumn;
TScreenVerificationResult VerificationResult;
int VerifResultCollAddedCount = 0;
// Don't validate posted rows
if ((ARow.RowState ==
DataRowState.Deleted) ||
(ARow.BatchStatus ==
MFinanceConstants.BATCH_POSTED))
{
return true;
}
ValidationColumn = ARow.Table.Columns[
AGiftBatchTable.ColumnBankAccountCodeId];
// Bank Account Code must be active
if (!ARow.IsBankAccountCodeNull() &&
(AAccountTableRef != null))
{
AAccountRow foundRow = (AAccountRow)
AAccountTableRef.Rows.Find(
new object[] {
ARow.LedgerNumber,
ARow.BankAccountCode });
VerificationResult = (foundRow == null) ?
new TScreenVerificationResult(
new TVerificationResult(AContext,
String.Format(Catalog.GetString(
"Unknown bank account code '{0}'."),
ARow.BankAccountCode),
TResultSeverity.Resv_Critical),
ValidationColumn) : null;
if (AVerificationResultCollection
.Auto_Add_Or_AddOrRemove(
AContext, VerificationResult))
{
VerifResultCollAddedCount++;
}
/* ... 6 more bank-account checks,
each populating the ref
collection in-place ... */
}
return VerifResultCollAddedCount == 0;
}
}
}
Source: Gift.Validation.cs lines 65–130 (the full method continues to line 280+ with currency/period/account-property checks).
Target: typed exceptions + ProblemDetails middleware
// Errors/GiftServiceError.cs (hierarchy)
public abstract class GiftServiceError(
string code,
int status,
string detail,
IDictionary<string, object?>? extensions = null)
: Exception(detail)
{
public string Code => code;
public int StatusCode => status;
public IDictionary<string, object?> Extensions
=> extensions
?? new Dictionary<string, object?>();
}
public sealed class
UnknownBankAccountException(
int ledger, string code)
: GiftServiceError(
"gift.batch.unknown_bank_account",
422,
$"Unknown bank account code '{code}'.",
new Dictionary<string, object?>
{
["ledgerNumber"] = ledger,
["bankAccountCode"] = code
});
// Validation/GiftBatchValidator.cs
// (the rule that previously populated
// TVerificationResultCollection)
public sealed class GiftBatchValidator(
GiftDbContext db) : IGiftBatchValidator
{
public async Task EnsureValidAsync(
GiftBatch batch, CancellationToken ct)
{
if (batch.BatchStatus == BatchStatus.Posted)
throw new
BatchAlreadyPostedException(
batch.LedgerNumber,
batch.BatchNumber);
var account = await db.Accounts
.Where(a =>
a.LedgerNumber == batch.LedgerNumber
&& a.AccountCode ==
batch.BankAccountCode)
.SingleOrDefaultAsync(ct);
if (account is null)
throw new UnknownBankAccountException(
batch.LedgerNumber,
batch.BankAccountCode);
if (!account.PostingStatus)
throw new
NonPostingAccountException(
batch.LedgerNumber,
batch.BankAccountCode);
/* further checks throw their own
typed exception subclasses */
}
}
// Program.cs (middleware wiring)
app.UseExceptionHandler(opts =>
{
opts.Run(async ctx =>
{
var ex = ctx.Features
.Get<IExceptionHandlerFeature>()?.Error;
if (ex is GiftServiceError g)
{
ctx.Response.StatusCode = g.StatusCode;
ctx.Response.ContentType =
"application/problem+json";
var problem = new ProblemDetails
{
Type = $"urn:gift:{g.Code}",
Title = g.Code,
Status = g.StatusCode,
Detail = g.Message
};
foreach (var kv in g.Extensions)
problem.Extensions[kv.Key] = kv.Value;
await ctx.Response
.WriteAsJsonAsync(problem);
}
});
});
Error code gift.batch.unknown_bank_account is the stable contract; client localizes via Angular i18n message bundle keyed on it.
Translation Notes
Ref-parameter collection → typed exception. ref TVerificationResultCollection — populated in-place across the validation method — becomes a typed exception thrown at the first failure. The first-failure-stops behaviour is the conscious change; it matches modern HTTP semantics where 422 ≠ 200.
Severity → HTTP status. TResultSeverity.Resv_Critical on a posted-batch mutation becomes HTTP 409 Conflict via BatchAlreadyPostedException; on an unknown bank-account code it becomes 422 Unprocessable Entity via UnknownBankAccountException. Each subclass picks its status code intentionally; the legacy code returned false with no HTTP semantics at all.
i18n key → stable error code. The legacy Catalog.GetString("Unknown bank account code '{0}'.") emits a server-localized message string. The modern code = "gift.batch.unknown_bank_account" is locale-independent; the client renders the localized message via Angular i18n keyed on the code. Translations live in messages.de.xlf / messages.en.xlf on the client side, not the server.
Per-rule context surfaced in extensions. RFC 7807 allows arbitrary additional fields under extensions. The ledgerNumber and bankAccountCode values that the legacy verification result carried inline are surfaced as typed extension properties — the client UI can highlight the offending field on the form by looking up the extension key.
Posted-batch immutability becomes a guard. The legacy if (BatchStatus == BATCH_POSTED) return true; early-out was a "skip validation, the row is read-only" signal; the modern equivalent throws BatchAlreadyPostedException from any service method that attempts to mutate a posted-batch row — a stronger contract because the rule is enforced at the service boundary, not at the validator. This is BR-GFT-005's modern shape.
Cross-reference: BR-GFT-005 (posted-batch immutability, 0.86 confidence) and BR-GFT-006 (modified-detail flag idempotence, 0.85 confidence) both translate via this pattern. BR-GFT-006's per-detail enumeration becomes the extensions.alreadyAdjustedDetails array. The Given-When-Then specifications are in Section 9.
8.5 Section Boundary — What Belongs Here vs Section 9
The Gift Processing slice has two C# stacks — legacy .NET Framework 4.7 with .asmx + TDBTransaction + TVerificationResultCollection, and modern .NET 8 with Minimal APIs + EF Core 8 + RFC 7807 ProblemDetails. Section 8 is deliberately scoped to the framework-pattern translations between those two stacks: how an .asmx RPC endpoint becomes a Minimal API, how a TDBTransaction delegate becomes IDbContextTransaction, how a synchronous render becomes a Service Bus queued worker, and how a TVerificationResultCollection becomes a typed exception caught by middleware.
These framework-pattern translations are interesting precisely because both source and target are C#: the syntactic shift is what makes each translation worth showing — an idiomatic shift, not a language-family translation. The four examples above each cite a BR-GFT-XXX identifier in their notes column (BR-GFT-001, 002, 004, 005, 006), but only for the "the framework wiring above is what enforces this rule's invariant" framing. The rule's Given-When-Then specification, behavioral-fidelity classification (preserved-exactly vs deliberately-tightened vs flagged-for-PO-review), source citation, and target validator code all live in Section 9 (Business Rules Analysis) to avoid duplication. This split is most natural when source and target share a language family — the framework-vs-business-rule boundary is what keeps each section's job distinct.
One Gift-Processing-specific addition to the boundary contract: Translation #3 (Service Bus queued worker) crosses the service boundary, not just the framework boundary. The other three translations are within-process idiom shifts; the queued-worker pattern is the load-bearing seam between gift-processing-service and gift-receipting-service. Section 4 owns the service-architecture rationale; Section 8 shows the translation idiom; Section 9 (BR-GFT-004 receipt eligibility, BR-GFT-005 posted-batch immutability) carries the invariants the queued message must respect. The three sections are complementary readings of the same architectural decision.
Continue to Section 10: Data Mapping Strategy for the schema-preserved gift-domain tables and the six net-new operational tables that support the queued-worker pattern (render_job, rendered_receipt, sepa_export_file, bank_import_upload, bank_import_line, idempotency_keys).
9. Business Rules Analysis
This section documents the behavioral rules extracted from the Finance — Gift Processing slice of OpenPetra, showing C# / SQL / JavaScript source implementation alongside .NET 8 / Angular 18 translations with formal Given-When-Then specifications. Sixteen distinct rules were retrieved through Sage's entity analysis and verified against actual source files in csharp/ICT/Petra/Server/lib/MFinance/Gift/, csharp/ICT/Petra/Server/lib/MFinance/BankImport/, csharp/ICT/Petra/Server/sql/, csharp/ICT/Petra/Shared/lib/MFinance/, and js-client/src/forms/Finance/Gift/GiftReceipting/.
9.1 Business Rules Overview
Sage's entity analysis surfaced 24 candidate business-rule entities across the Finance — Gift Processing and Donations Processing subjects, plus targeted sweeps on tax / receipt / SEPA / posted / motivation / batch / gift name fragments. After deduplicating rules that appeared in two or more clusters with identical source-file evidence (8 same-rule overlaps), and excluding rules that fell outside the slice scope or below the confidence threshold (8 entries — see Catalog § "Excluded Sage Entities"), 16 distinct rules govern the gift-processing slice. Thirteen of the sixteen rules clear the agent's high-confidence threshold (≥ 0.75); the remaining three sit in the 0.70 band and are presented with their evidence intact.
The rules were extracted from 13 distinct source files spanning three architectural surfaces:
- Server-side gift-processing C# —
Gift.Batch.cs,Gift.TaxDeductiblePct.cs,Gift.Adjustment.cs,Gift.gui.tools.cs(the 4 source files that hold the bulk of batch-state, tax-deductible, and motivation-defaulting policy) - Bank-import C# —
ImportFromMT940.cs,ImportFromCAMT.cs(German banking-code classification) - Shared finance utilities C# —
TaxDeductibility.cs(the percentage-bounds clamp and amount-derivation utility used by Gift.TaxDeductiblePct.cs) - SQL queries —
Gift.ReceiptPrinting.GetDonationsOfDonor.sql,ICH.HOSAReportGiftSummary.sql,Gift.Queries.ExtractDonorByAmount.sql(receipt eligibility, multi-currency aggregation, composite-key joins) - Database upgrade scripts —
Upgrade201911_201912.cs,Upgrade202206_202207.cs(the motivation-status correction and the SEPA-mandate column additions) - Import / export plumbing —
ImportExport.cs(the YML.GZ import default-values block) - Report templates —
methodofgiving.xml(the report-side posted-batch filter) - Web client —
PrintAnnualReceipts.js(the previous-calendar-year default for annual-receipt date range)
Rule Distribution by Category
| Category | Count | Description | Headline rule |
|---|---|---|---|
| Validation | 5 | Pre-condition checks on period, percentage bounds, dual-flag receipt eligibility, import defaults, composite-key integrity | BR-GFT-002 (period validation) |
| Calculation | 4 | Sequential numbering, tax-deductible amount recalculation, multi-currency three-amount tracking, annual-receipt default date range | BR-GFT-001 (sequential numbering) |
| State Transition | 3 | Posted-batch immutability, modified-detail reversal idempotence, motivation-detail activation | BR-GFT-005 (posted-batch immutability) |
| Workflow | 3 | German banking-code gift detection, partner-class motivation defaulting, recurring-gift SEPA mandate tracking | BR-GFT-007 (gift detection) |
| Authorization / Privacy | 1 | Confidential gift partner double-join | BR-GFT-016 |
Rule Discovery Methodology
Business rules were retrieved through Sage's entity catalog using get_entities_by_subject filtered to entity_type="business_rule" for both Finance — Gift Processing (7 rules) and Donations Processing (5 rules). To ensure breadth, eight additional search_entities sweeps were executed on the most likely rule-bearing name fragments (gift, tax, receipt, sepa, posted, motivation, batch) raising the candidate pool from 7 to 24. Pairwise comparison of rule descriptions and cited line references identified eight same-rule overlaps; the higher-confidence variant was retained and the cross-cluster convergence noted as additional evidence. The remaining catalog was filtered against the slice scope (excluding cross-cutting Partner-aggregate rules, GDPdU export rules that live in a separate finance-accounting tool, and Sponsorship-specific recurring-gift rules outside this slice) and a 0.50 confidence floor. BR-GFT-NNN identifiers were assigned by this analysis for traceability; they do not exist in the legacy source.
Each rule's Given-When-Then specification was derived by this workflow from Sage's plain-language description and verified against actual code retrieved via get_file_content_in_range. Of 8 file:line spot-checks performed, 7 were exact and 1 had a single-digit line drift (PrintAnnualReceipts.js — Sage cited line 25, actual is line 11; the file's 10-line GPL header offsets every cited line). Sage's basename match was correct in all cases.
Confidence-band finding for the financial code surface. Of 16 distinct rules, 13 clear the high-confidence threshold (≥ 0.75) and the remaining 3 sit at exactly 0.70. The mean confidence is 0.818. Two factors explain the strong lift: (a) gift-processing rules tend to be encoded in dedicated server-side methods with grep-able names (UpdateTaxDeductibiltyAmounts, GetLedgerDatePostingPeriod, BatchStatus = 'Posted') rather than buried inside form save handlers; (b) the 13 rule-bearing files cluster under MFinance/Gift/ and provide strong structural evidence for Sage's entity classifier. This means the catalog can be treated as authoritative without per-rule manual verification — the source code remains available for inspection, and any rule's concrete code has been spot-verified above.
9.2 Validation Rules
BR-GFT-002: Gift Batch Effective Date Period Validation
C# (MFinance/Gift/Gift.Batch.cs)
NewRow = AMainDS.AGiftBatch
.NewRowTyped(true);
NewRow.LedgerNumber =
ALedgerNumber;
NewRow.BatchNumber =
++ALedgerTbl[0]
.LastGiftBatchNumber;
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;
NewRow.ExchangeRateToBase = 1.0M;
NewRow.BatchDescription =
"PLEASE ENTER A DESCRIPTION";
NewRow.BankAccountCode =
info.GetDefaultBankAccount();
NewRow.BankCostCentre =
info.GetStandardCostCentre();
NewRow.CurrencyCode =
ALedgerTbl[0].BaseCurrency;
AMainDS.AGiftBatch.Rows.Add(NewRow);
.NET 8 (gift-processing-service)
public sealed class
FinancialPeriodValidator
{
private readonly
IAccountingPeriodRepository _periods;
public PeriodResolution Validate(
int ledgerNumber,
DateOnly proposed,
bool silentSnap)
{
var open = _periods
.GetOpenPeriods(ledgerNumber);
var match = open.FirstOrDefault(p =>
proposed >= p.StartDate
&& proposed <= p.EndDate);
if (match is not null)
{
return new PeriodResolution(
proposed,
match.Year,
match.Period);
}
if (!silentSnap)
{
throw new
FinancialPeriodClosedException(
ledgerNumber, proposed);
}
// Force-fit: snap to nearest
// open period boundary
var snapped =
open.SnapToNearest(proposed);
return new PeriodResolution(
snapped.Date,
snapped.Year,
snapped.Period);
}
}
Business Rule Analysis
Given: A gift batch is being created or its effective date is being changed; ledger N has a defined set of open accounting periods
When: TFinancialYear.GetLedgerDatePostingPeriod is called with the proposed date and the force-fit flag
Then: If the date is in an open period, BatchYear/BatchPeriod are set; if the date is closed and force-fit = true, the date is snapped to the nearest open boundary; if force-fit = false and the date is closed, the validation returns a verification error and the batch creation fails
Source: csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.Batch.cs line 114
Confidence: 0.90 (STRUCTURAL; convergent across three subject clusters — Finance — Gift Processing, Finance — Accounting, and Donations Processing)
Behavioral Fidelity: Direct mapping. The legacy force-fit Boolean becomes an explicit silentSnap parameter on the validator. Failure mode upgrades from TVerificationResultCollection add-and-return to FinancialPeriodClosedException → HTTP 409. The auto-snap-to-nearest-open-period semantics are preserved.
BR-GFT-010: Tax-Deductible Percentage Bounds 0..100%
C# (Shared/lib/MFinance/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;
}
.NET 8 (TaxDeductiblePercentage value object)
public readonly record struct
TaxDeductiblePercentage
{
public decimal Value { get; }
public TaxDeductiblePercentage(
decimal value)
{
if (value < 0m || value > 100m)
{
throw new
TaxDeductibleOutOfBoundsException(
value);
}
Value = value;
}
public static
TaxDeductiblePercentage Default =>
new(0m);
}
// FluentValidation rule
public sealed class
GiftDetailDtoValidator
: AbstractValidator<GiftDetailDto>
{
public GiftDetailDtoValidator()
{
RuleFor(x => x.TaxDeductiblePct)
.InclusiveBetween(0m, 100m)
.WithMessage(
"Tax-deductible percentage " +
"must be between 0 and 100.");
}
}
Business Rule Analysis
Given: A GiftDetailRow.TaxDeductiblePct is being normalized prior to amount recalculation
When: TaxDeductibility.UpdateTaxDeductibiltyAmounts(ref AGiftDetail) runs
Then: Null is normalized to 0.0m; the value is then clamped to [0, 100] via Math.Max(value, 0) followed by Math.Min(value, 100); legacy silently snaps out-of-range; modern formulation rejects with HTTP 400
Source: csharp/ICT/Petra/Shared/lib/MFinance/TaxDeductibility.cs line 50
Confidence: 0.80 (STRUCTURAL; single-source but the bounds-clamp idiom is unambiguous and the same value object is referenced by BR-GFT-003)
Behavioral Fidelity: Direct mapping with one deliberate tightening. Modernization tightening: the legacy silently clamps invalid input; the modern formulation rejects with a 400 instead. Recommend product-owner sign-off before code generation — this is a UX change, not a behavior preservation.
BR-GFT-004: Tax-Deductible Receipt Dual-Flag Eligibility
SQL (Gift.ReceiptPrinting.GetDonationsOfDonor.sql)
SELECT a_gift.a_date_entered_d,
a_gift.a_first_time_gift_l,
a_gift_detail.a_detail_number_i,
a_gift_detail
.a_gift_transaction_amount_n,
a_gift_detail.a_gift_amount_n,
a_gift_detail
.a_tax_deductible_amount_n,
...
Recipient.p_partner_short_name_c,
a_motivation_detail
.a_motivation_detail_desc_c
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 a_gift_batch
.a_ledger_number_i = ?
AND a_gift_batch
.a_batch_status_c = 'Posted'
AND a_gift.a_ledger_number_i =
a_gift_batch.a_ledger_number_i
AND a_gift.a_batch_number_i =
a_gift_batch.a_batch_number_i
AND a_gift_detail.a_ledger_number_i =
a_gift_batch.a_ledger_number_i
AND a_gift_detail.a_batch_number_i =
a_gift_batch.a_batch_number_i
AND a_gift_detail
.a_gift_transaction_number_i =
a_gift.a_gift_transaction_number_i
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
.NET 8 (gift-receipting-service)
public sealed class
ReceiptEligibilityFilter
{
public IQueryable<GiftDetail> Apply(
IQueryable<GiftDetail> q)
{
return q.Where(d =>
d.Gift.Batch.Status ==
BatchStatus.Posted
&& d.Gift.PrintReceipt
&& d.MotivationDetail
.ReceiptEligible
&& !d.ModifiedDetail);
}
}
public async Task<
IReadOnlyList<ReceiptLine>>
BuildAnnualReceiptAsync(
long donorKey,
int ledgerNumber,
DateOnly start,
DateOnly end,
CancellationToken ct)
{
var query = _db.GiftDetails
.Include(d => d.Gift)
.ThenInclude(g => g.Batch)
.Include(d =>
d.MotivationDetail)
.Include(d => d.RecipientPartner)
.Where(d =>
d.Gift.DonorKey == donorKey
&& d.Gift.Batch.LedgerNumber
== ledgerNumber
&& d.Gift.Batch
.GlEffectiveDate >= start
&& d.Gift.Batch
.GlEffectiveDate <= end);
query = _eligibility.Apply(query);
return await query
.Select(d => ToReceiptLine(d))
.ToListAsync(ct);
}
Business Rule Analysis
Given: An annual-receipt batch run is generating receipts for donor D for date-range R
When: The receipt query executes against the gift-domain schema
Then: A gift detail line appears on the receipt only if all four conditions hold: (a) batch.status = 'Posted', (b) gift.print_receipt_l = 1, (c) motivation_detail.receipt_l = 1, (d) gift_detail.modified_detail_l = 0
Source: csharp/ICT/Petra/Server/sql/Gift.ReceiptPrinting.GetDonationsOfDonor.sql line 37; XmlReports/FinancialDevelopment/methodofgiving.xml line 48
Confidence: 0.87 (STRUCTURAL; convergent across two clusters, with the same dual-flag SQL pattern referenced by both the receipt-printing query and the report-engine template)
Behavioral Fidelity: Direct mapping. The four-condition WHERE clause becomes a typed LINQ predicate composable across all receipt-related queries. Headline rule for gift processing — preserving these four conditions exactly is the load-bearing receipt eligibility policy and is golden-master-tested per the target-architecture handoff's testing decision.
BR-GFT-011: Gift Import Default Values
C# (MSysMan/ImportExport.cs)
if (ATableName == "a_gift")
{
if (!RowDetails.ContainsKey(
"LinkToPreviousGift")
|| RowDetails["LinkToPreviousGift"]
== string.Empty)
{
RowDetails["LinkToPreviousGift"] =
"false";
}
if (!RowDetails.ContainsKey(
"PrintReceipt")
|| RowDetails["PrintReceipt"]
== string.Empty)
{
RowDetails["PrintReceipt"] =
"true";
}
}
.NET 8 (GiftImportRecord DTO)
public sealed record GiftImportRecord
{
public required long DonorKey
{ get; init; }
public required decimal Amount
{ get; init; }
[DefaultValue(false)]
public bool LinkToPreviousGift
{ get; init; } = false;
[DefaultValue(true)]
public bool PrintReceipt
{ get; init; } = true;
public string? Comment
{ get; init; }
public string MotivationGroup
{ get; init; } = string.Empty;
public string MotivationDetail
{ get; init; } = string.Empty;
}
// On JSON deserialization, missing
// fields use the property defaults.
// Same defaults apply to direct API
// calls, so the two ingest paths share
// behavior.
Business Rule Analysis
Given: An import is processing rows for table a_gift
When: A row is parsed and the RowDetails dictionary is being built
Then: If LinkToPreviousGift is missing or empty, it defaults to false; if PrintReceipt is missing or empty, it defaults to true
Source: csharp/ICT/Petra/Server/lib/MSysMan/ImportExport.cs line 514
Confidence: 0.80 (CATEGORICAL; convergent across two entities, well-documented in the import code)
Behavioral Fidelity: Direct mapping. The dictionary-default idiom translates to record-property defaults. The same defaults apply to direct API gift-creation so the two ingest paths share behavior — a deliberate consistency improvement over the legacy where the import path and the API path could diverge.
Additional validation rules are documented in the catalog: BR-GFT-012 (Gift Composite-Key Hierarchy Integrity, 0.77 — preserved as EF Core composite primary keys, no schema change).
9.3 Calculation Rules
BR-GFT-001: Sequential Gift Batch Numbering
C# (MFinance/Gift/Gift.Batch.cs)
AGiftBatchRow NewRow = null;
try
{
NewRow = AMainDS.AGiftBatch
.NewRowTyped(true);
NewRow.LedgerNumber =
ALedgerNumber;
// Atomic pre-increment of the
// ledger's LastGiftBatchNumber:
// serializes batch creation per
// ledger via the ledger-row lock
// taken by the enclosing
// TDBTransaction.
NewRow.BatchNumber =
++ALedgerTbl[0]
.LastGiftBatchNumber;
Int32 BatchYear, BatchPeriod;
TFinancialYear
.GetLedgerDatePostingPeriod(
ALedgerNumber,
ref ADateEffective,
out BatchYear,
out BatchPeriod,
ATransaction,
AForceEffectiveDateToFit);
// ... batch defaults ...
AMainDS.AGiftBatch.Rows.Add(NewRow);
}
catch (Exception ex)
{
TLogging.LogException(ex,
Utilities.GetMethodSignature());
throw;
}
return NewRow;
.NET 8 (gift-processing-service)
public async Task<GiftBatch>
CreateBatchAsync(
int ledgerNumber,
DateOnly effectiveDate,
bool silentSnap,
CancellationToken ct)
{
await using var tx = await _db
.Database
.BeginTransactionAsync(
IsolationLevel.Serializable,
ct);
var ledger = await _db.Ledgers
.Where(l =>
l.LedgerNumber == ledgerNumber)
.FirstAsync(ct);
// Period validation (BR-GFT-002)
var resolution = _periods.Validate(
ledgerNumber,
effectiveDate,
silentSnap);
// Sequential numbering (BR-GFT-001)
ledger.LastGiftBatchNumber += 1;
var batchNumber =
ledger.LastGiftBatchNumber;
var batch = new GiftBatch
{
LedgerNumber = ledgerNumber,
BatchNumber = batchNumber,
Status = BatchStatus.Unposted,
BatchYear = resolution.Year,
BatchPeriod = resolution.Period,
GlEffectiveDate =
resolution.Date,
ExchangeRateToBase = 1.0m,
BatchDescription =
"PLEASE ENTER A DESCRIPTION",
CurrencyCode =
ledger.BaseCurrency
};
_db.GiftBatches.Add(batch);
await _db.SaveChangesAsync(ct);
await tx.CommitAsync(ct);
return batch;
}
Business Rule Analysis
Given: A user creates a new gift batch in ledger N
When: The batch-creation method executes NewRow.BatchNumber = ++ALedgerTbl[0].LastGiftBatchNumber
Then: The new batch receives the next sequential number for that ledger; the ledger row's counter is incremented in the same transaction; concurrent batch-creation attempts on the same ledger serialize via the underlying row lock
Source: csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.Batch.cs line 111
Confidence: 0.95 (STRUCTURAL; the highest-confidence rule in the slice; merged from two entities — GiftBatchSequentialNumbering 0.95 and GiftBatchConsecutiveNumbering 0.75 — both citing line 111 with complementary descriptions)
Behavioral Fidelity: Preserved exactly. The legacy TDBTransaction row lock translates to IsolationLevel.Serializable on EF Core's IDbContextTransaction. Same single-source-of-truth invariant: a_ledger.last_gift_batch_number_i is the only counter for the slice.
BR-GFT-003: Tax-Deductible Recalculation on Percentage Change
C# (Gift.TaxDeductiblePct.cs)
db.SelectDT(Table, Query, Transaction);
// update fields for each row
for (int i = 0;
i < Table.Rows.Count;
i++)
{
AGiftDetailRow Row = Table[i];
Row.TaxDeductiblePct = ANewPct;
TaxDeductibility
.UpdateTaxDeductibiltyAmounts(
ref Row);
}
AGiftDetailAccess.SubmitChanges(
Table, Transaction);
SubmissionOK = true;
.NET 8 (TaxDeductibilityCalculator)
public sealed class
TaxDeductibilityCalculator
{
public void Apply(
GiftDetail row,
TaxDeductiblePercentage newPct)
{
row.TaxDeductiblePct =
newPct.Value;
// Transaction-currency split
row.TaxDeductibleAmount =
row.GiftTransactionAmount
* (newPct.Value / 100m);
row.NonDeductibleAmount =
row.GiftTransactionAmount
- row.TaxDeductibleAmount;
// Base-currency split
row.TaxDeductibleAmountBase =
row.GiftAmount
* (newPct.Value / 100m);
row.NonDeductibleAmountBase =
row.GiftAmount
- row.TaxDeductibleAmountBase;
}
}
public async Task<int>
AdjustTaxDeductiblePctAsync(
long recipientKey,
DateOnly fromDate,
TaxDeductiblePercentage newPct,
CancellationToken ct)
{
var rows = await _db.GiftDetails
.Where(/* recipient + date */)
.ToListAsync(ct);
foreach (var row in rows)
{
_calculator.Apply(row, newPct);
}
await _db.SaveChangesAsync(ct);
return rows.Count;
}
Business Rule Analysis
Given: A user (or batch operation) updates the TaxDeductiblePct on a GiftDetail row
When: The change is applied via the loop over the SQL-selected rows
Then: For every affected row, TaxDeductibility.UpdateTaxDeductibiltyAmounts(ref Row) recalculates the four amount fields (deductible/non-deductible × transaction/base currency); rows are written back via SubmitChanges
Source: csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.TaxDeductiblePct.cs line 138
Confidence: 0.88 (STRUCTURAL; convergence-merged from 4 entities — the highest-merge-count rule in the catalog, indicating Sage's classifier saw this from multiple perspectives with identical evidence)
Behavioral Fidelity: Direct mapping. Posted-gift modifications continue to be allowed (they are how field corrections happen) and emit a GiftDetailAdjusted domain event for downstream audit. The four-amount derivation (deductible/non-deductible × transaction/base) is preserved exactly.
BR-GFT-008: Annual Receipt Default Date Range = Previous Calendar Year
JavaScript (PrintAnnualReceipts.js)
$(function() {
var year =
(new Date()).getYear()
+ 1900 - 1;
$("#StartDate").val(
year + "-01-01");
$("#EndDate").val(
year + "-12-31");
LoadDefaultTemplateFiles();
});
Angular 18 (PrintAnnualReceiptsComponent)
@Component({
selector:
'app-print-annual-receipts',
standalone: true,
imports: [ReactiveFormsModule],
templateUrl:
'./print-annual-receipts.html'
})
export class
PrintAnnualReceiptsComponent
implements OnInit
{
form = this.fb.group({
startDate:
['', Validators.required],
endDate:
['', Validators.required],
templateFiles: [[]]
});
constructor(
private fb: FormBuilder,
private receipts:
ReceiptingClient
) {}
ngOnInit(): void {
const lastYear =
new Date().getFullYear() - 1;
this.form.patchValue({
startDate:
`${lastYear}-01-01`,
endDate:
`${lastYear}-12-31`
});
this.receipts
.loadDefaultTemplates()
.subscribe(/* ... */);
}
}
Business Rule Analysis
Given: A user opens the Print Annual Receipts screen
When: The on-load handler computes the default date range
Then: The current year minus 1 is assigned to StartDate as YYYY-01-01 and EndDate as YYYY-12-31; the form fields pre-populate with these values; the user can edit them before submitting
Source: js-client/src/forms/Finance/Gift/GiftReceipting/PrintAnnualReceipts.js line 11 (Sage cited line 25; the actual substantive code is line 11 — a 10-line GPL header offsets the citation. Sage's basename match is correct; the line drift is a known anchor artifact.)
Confidence: 0.85 (STRUCTURAL; merged with the lower-confidence AnnualReceiptDateRangeDerivation 0.50 entity at the same code location)
Behavioral Fidelity: Direct mapping. The legacy (new Date()).getYear() + 1900 - 1 idiom (the +1900 compensates for IE-era getYear behaviour) translates to the modern getFullYear() - 1. User-override semantics preserved.
Additional calculation rule: BR-GFT-009 (Multi-Currency Three-Amount Tracking, 0.83) — preserved at the schema level as a six-column shape on a_gift_detail (transaction-currency: a_gift_transaction_amount_n, a_tax_deductible_amount_n, a_non_deductible_amount_n; base-currency: a_gift_amount_n, a_tax_deductible_amount_base_n, a_non_deductible_amount_base_n). EF Core entities expose them as typed Money value objects. ICH HOSA reports continue to aggregate by base currency only.
9.4 State Transition Rules
BR-GFT-005: Posted-Batch-Only Financial Reporting (Posted-Batch Immutability)
XML Report Template (methodofgiving.xml)
<detailreports>
<detailreport
id="Get all the gifts with selected
Method of Giving"
action="GiftTransactions/
GiftTransactionsDefaultDetail
.xml">
<parameter name="title2"
value="Gift Totals of Method of
Giving {{param_Method}}"/>
<parameter name="period1"
value="From {{param_start_date}}
to {{param_end_date}}"/>
<detailreportquery>
batch.a_gl_effective_date_d
BETWEEN
{#param_start_date#} AND
{#param_end_date#}
AND batch.a_batch_status_c
= "Posted"
AND motivation.a_receipt_l = TRUE
AND (gift.a_method_of_giving_code_c
= {param_Method}
OR ({param_Method} = ''
AND gift
.a_method_of_giving_code_c
IS NULL))
AND batch.a_currency_code_c
= {param_Currency}
</detailreportquery>
</detailreport>
</detailreports>
.NET 8 (BatchStatus invariant + write guard)
public enum BatchStatus
{
Unposted,
Posted,
Cancelled
}
// Write guard: any service method
// that mutates a batch must call
// EnforceMutable first.
internal static class
GiftBatchInvariants
{
public static void EnforceMutable(
GiftBatch batch)
{
if (batch.Status ==
BatchStatus.Posted)
{
throw new
BatchAlreadyPostedException(
batch.LedgerNumber,
batch.BatchNumber);
}
}
}
// Posted-batch report filter: typed
// LINQ predicate composable across
// every gift report query.
public static class
GiftBatchQueryExtensions
{
public static
IQueryable<GiftBatch>
OnlyPosted(
this IQueryable<GiftBatch> q)
=> q.Where(b =>
b.Status ==
BatchStatus.Posted);
}
Business Rule Analysis
Given: A user runs any gift report; or any service method attempts to mutate a gift batch
When: The query / mutation is dispatched
Then: Reports filter to batch.status = 'Posted' — unposted, cancelled, or in-progress batches are silently excluded; mutation attempts on posted batches throw BatchAlreadyPostedException → HTTP 409. The only legitimate "change" to a posted batch is via gift adjustment, which writes a paired reversal/correction rather than mutating the original (BR-GFT-006).
Source: XmlReports/FinancialDevelopment/methodofgiving.xml line 33 (and pervasive across the gift-report SQL library)
Confidence: 0.86 (STRUCTURAL; convergent across two clusters — named PostedBatchFinancialIntegrityRule as both a Forms-cluster invariant and a Database-cluster validation)
Behavioral Fidelity: Preserved exactly. Headline state-transition rule for gift processing — the posted-batch immutability invariant is the load-bearing financial-integrity contract for the slice. Compliance-posture is preserved as a typed exception above EF Core; no DDL change.
BR-GFT-006: Modified-Detail Flag Blocks Duplicate Reversals
C# (Gift.Adjustment.cs)
foreach (DataRowView RowView in
AGiftDS.AGiftDetail.DefaultView)
{
AGiftDetailRow GiftDetailRow =
(AGiftDetailRow)RowView.Row;
if (GiftDetailRow.ModifiedDetail)
{
Message += "\n" + String.Format(
Catalog.GetString(
"Gift {0} with Detail {1} " +
"in Batch {2}"),
GiftDetailRow
.GiftTransactionNumber,
GiftDetailRow.DetailNumber,
GiftDetailRow.BatchNumber);
GiftCount++;
}
}
if (GiftCount != 0)
{
if (GiftCount > 1)
{
Message = String.Format(
Catalog.GetString(
"Cannot reverse or adjust " +
"the following gifts:"))
+ "\n" + Message
+ "\n\n"
+ Catalog.GetString(
"They have already been " +
"adjusted or reversed.");
}
else if (GiftCount == 1)
{
Message = String.Format(
Catalog.GetString(
"Cannot reverse or adjust " +
"the following gift:"))
+ "\n" + Message
+ "\n\n"
+ Catalog.GetString(
"It has already been " +
"adjusted or reversed.");
}
AMessages.Add(
new TVerificationResult(null,
Message,
TResultSeverity.Resv_Critical));
}
.NET 8 (GiftAdjustmentService)
public sealed class
GiftAlreadyAdjustedException
: DomainException
{
public IReadOnlyList<
GiftDetailReference>
AlreadyAdjusted { get; }
public GiftAlreadyAdjustedException(
IReadOnlyList<
GiftDetailReference> refs)
: base(
"Cannot reverse or adjust " +
"gifts that have already " +
"been adjusted or reversed.")
{
AlreadyAdjusted = refs;
}
}
public async Task PrepareReversalAsync(
IReadOnlyList<
GiftDetailReference> targets,
CancellationToken ct)
{
var rows = await _db.GiftDetails
.Where(d =>
targets.Select(t =>
t.CompositeKey)
.Contains(d.CompositeKey))
.ToListAsync(ct);
var alreadyAdjusted = rows
.Where(r => r.ModifiedDetail)
.Select(r => r.ToReference())
.ToList();
if (alreadyAdjusted.Count > 0)
{
// All-or-nothing: even one
// already-adjusted detail
// aborts the entire reversal.
throw new
GiftAlreadyAdjustedException(
alreadyAdjusted);
}
// ... write the paired reversal/
// correction rows ...
}
Business Rule Analysis
Given: An operator initiates a reverse-or-adjust operation on a set of gift details
When: The pre-flight scan iterates the prepared GiftBatchTDS and inspects each row's ModifiedDetail flag
Then: For every row where ModifiedDetail = true, a per-detail message is appended; if any rows are already-adjusted, the entire reversal is aborted with Resv_Critical verification (legacy) / HTTP 409 with the per-detail enumeration in extensions.alreadyAdjustedDetails (modern). All-or-nothing semantics — partial reversals require a re-selected detail set.
Source: csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.Adjustment.cs line 202
Confidence: 0.85 (STRUCTURAL; convergence-merged from 3 entities — same code location, REST/SOAP/data-access perspectives all converged on the same idempotence-guard pattern)
Behavioral Fidelity: Direct mapping. The all-or-nothing semantics are preserved. The Catalog.GetString i18n calls move from PO-file lookups to Angular i18n + .NET .resx; the singular/plural message variants ("the following gift" / "the following gifts") preserve as resource-keyed translations.
Additional state-transition rule: BR-GFT-013 (Motivation Detail Active-Status Default, 0.77) — preserved as MotivationDetail.IsActive with default true; the historical activation-state correction in Upgrade201911_201912.cs:48 remains in the migration history but the bug it fixed does not regress because the new entity defaults to active.
9.5 Workflow Rules
BR-GFT-007: German Banking Code Gift Detection
C# (BankImport/ImportFromMT940.cs)
row.DateEffective = tr.valueDate;
row.TransactionAmount = tr.amount;
row.Description = tr.description;
row.TransactionTypeCode = tr.typecode;
// see the codes:
// http://www.hettwer-beratung.de/
// sepa-spezialwissen/...
if ((row.TransactionTypeCode == "052")
|| (row.TransactionTypeCode == "051")
|| (row.TransactionTypeCode == "053")
|| (row.TransactionTypeCode == "067")
|| (row.TransactionTypeCode == "068")
|| (row.TransactionTypeCode == "069")
|| (row.TransactionTypeCode == "119")
|| (row.TransactionTypeCode == "152")
|| (row.TransactionTypeCode == "166")
|| (row.TransactionTypeCode == "169"))
{
row.TransactionTypeCode +=
MFinanceConstants
.BANK_STMT_POTENTIAL_GIFT;
}
MainDS.AEpTransaction.Rows.Add(row);
.NET 8 (gift-receipting-service / BankImportModule)
// appsettings.json
// {
// "GiftDetection": {
// "GermanBankingCodes": [
// "052", "051", "053",
// "067", "068", "069",
// "119", "152", "166", "169"
// ]
// }
// }
public sealed class GiftDetectionRule
{
private readonly HashSet<string>
_giftCodes;
public GiftDetectionRule(
IOptions<
GiftDetectionOptions> opt)
{
_giftCodes = new(
opt.Value.GermanBankingCodes,
StringComparer.Ordinal);
}
public TransactionClassification
Classify(
BankImportLine line)
{
if (_giftCodes.Contains(
line.TransactionTypeCode)
&& line.TransactionAmount > 0m)
{
return TransactionClassification
.PotentialGift;
}
return TransactionClassification
.Other;
}
}
Business Rule Analysis
Given: A bank statement file (MT940 or CAMT) has been uploaded and is being parsed
When: Each parsed transaction's TransactionTypeCode is compared against the curated German banking-code set AND amount > 0
Then: Matching transactions are classified as PotentialGift; non-matching transactions bypass the gift-classification pipeline downstream
Source: csharp/ICT/Petra/Server/lib/MFinance/BankImport/ImportFromMT940.cs line 99; csharp/ICT/Petra/Server/lib/MFinance/BankImport/ImportFromCAMT.cs line 193
Confidence: 0.85 (STRUCTURAL; convergent across two file locations — both bank-import format parsers carry the same code list)
Behavioral Fidelity: Direct mapping with one extraction. The hard-coded code list is externalized to appsettings.json so future codes can be added without redeploy. The string-suffix tag (BANK_STMT_POTENTIAL_GIFT) becomes a typed TransactionClassification enum. Product-owner note: Sage's description abbreviated the code set to 4 codes; the actual source has 10 codes. Confirm during UAT that the seed config matches production-current.
BR-GFT-014: Partner-Class-Driven Motivation Defaulting
C# (MFinance/Gift/Gift.gui.tools.cs)
// 1. Look up partner-specific
// motivation override
bool KeyMinFound = false;
AMotivationDetailTable
MotivationDetailTable
= AMotivationDetailAccess
.LoadViaPPartner(
APartnerKey, readTransaction);
if (MotivationDetailTable != null
&& MotivationDetailTable.Rows.Count
> 0)
{
foreach (AMotivationDetailRow Row
in MotivationDetailTable.Rows)
{
if (Row.MotivationStatus)
{
motivationGroup =
MotivationDetailTable[0]
.MotivationGroupCode;
motivationDetail =
MotivationDetailTable[0]
.MotivationDetailCode;
KeyMinFound = true;
break;
}
}
}
// 2. Fallback: unit-type switch
if (!KeyMinFound)
{
PUnitTable pUnitTable =
PUnitAccess.LoadByPrimaryKey(
APartnerKey, readTransaction);
if (pUnitTable.Rows.Count == 1)
{
PUnitRow unitRow = pUnitTable[0];
switch (unitRow.UnitTypeCode)
{
case MPartnerConstants
.UNIT_TYPE_AREA:
case MPartnerConstants
.UNIT_TYPE_FUND:
case MPartnerConstants
.UNIT_TYPE_FIELD:
motivationDetail =
MFinanceConstants
.GROUP_DETAIL_FIELD;
break;
case MPartnerConstants
.UNIT_TYPE_KEYMIN:
motivationDetail =
MFinanceConstants
.GROUP_DETAIL_KEY_MIN;
break;
// others: no default
}
}
}
.NET 8 (MotivationResolver service)
public sealed class MotivationResolver
{
private readonly GiftDbContext _db;
public MotivationResolver(
GiftDbContext db) => _db = db;
public async Task<
ResolvedMotivation?>
ResolveDefaultAsync(
long partnerKey,
CancellationToken ct)
{
// 1. Partner-specific override
var partnerOverride =
await _db.MotivationDetails
.Where(m =>
m.RecipientPartnerKey
== partnerKey
&& m.IsActive)
.FirstOrDefaultAsync(ct);
if (partnerOverride is not null)
{
return new ResolvedMotivation(
partnerOverride
.MotivationGroupCode,
partnerOverride
.MotivationDetailCode);
}
// 2. Unit-type fallback
var unit = await _db.Units
.Where(u =>
u.PartnerKey == partnerKey)
.FirstOrDefaultAsync(ct);
if (unit is null)
return null;
return unit.UnitType switch
{
UnitType.Area
or UnitType.Fund
or UnitType.Field
=> new ResolvedMotivation(
"GIFT",
"FIELD"),
UnitType.KeyMin
=> new ResolvedMotivation(
"GIFT",
"KEYMIN"),
_ => null
};
}
}
Business Rule Analysis
Given: A gift entry user has selected a recipient partner with key K on a gift-detail line
When: The "auto-resolve motivation" hook fires
Then: The partner-specific override path runs first (LoadViaPPartner); if active match, fill motivation group + detail. Otherwise the unit-type switch runs: AREA/FUND/FIELD → FIELD; KEYMIN → KEYMIN; other unit types fall through to "no default" (user must enter manually).
Source: csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.gui.tools.cs lines 105 (override) and 132 (switch); folded with UnitTypeMotivationMapping 0.70 (same switch block)
Confidence: 0.70 (STRUCTURAL; medium-band — the rule itself is concrete but Sage classified two facets at 0.70 which when folded reflect a single coherent default-resolution policy)
Behavioral Fidelity: Direct mapping. Recommended for product-owner review: the unit-type switch covers four of nine unit types; the other five fall through to "no default." Confirm during UAT whether this is intentional or incomplete coverage.
Additional workflow rule: BR-GFT-015 (Recurring-Gift SEPA Mandate Tracking, 0.70) — the a_recurring_gift table carries a_sepa_mandate_reference_c (varchar(35)) and a_sepa_mandate_given_d (date) per the 2022.07 DB upgrade; the SEPA export worker generates ISO 20022 pain.008 XML with these fields populated. Recommended for product-owner review: the donor-key + YYYYMMDD format convention referenced in the target-architecture handoff is not enforced by the schema (only varchar(35) length); confirm whether to codify in a typed value object.
9.6 Authorization & Privacy Rules
BR-GFT-016: Confidential Gift Privacy via Partner Double-Join
SQL (Gift.ReceiptPrinting.GetDonationsOfDonor.sql)
SELECT ...
GiftDestination
.p_partner_short_name_c
AS FieldName,
Recipient
.p_partner_short_name_c
AS RecipientName,
Recipient
.p_partner_short_name_loc_c
AS RecipientLocalName,
Recipient.p_partner_key_n
AS RecipientKey,
...
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 ...
AND 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
...
.NET 8 (EF Core + DTO)
public class GiftDetail
{
public long RecipientLedgerNumber
{ get; set; }
public long RecipientPartnerKey
{ get; set; }
public Partner
RecipientLedgerPartner
{ get; set; } = null!;
public Partner RecipientPartner
{ get; set; } = null!;
}
public sealed record ReceiptLine(
DateOnly DateEntered,
decimal TransactionAmount,
string Currency,
decimal TaxDeductibleAmount,
decimal NonDeductibleAmount,
string FieldName,
string? RecipientName,
string MotivationDescription);
public ReceiptLine ToReceiptLine(
GiftDetail d,
bool donorMayKnowRecipient)
{
return new ReceiptLine(
d.Gift.DateEntered,
d.GiftTransactionAmount,
d.Gift.Batch.CurrencyCode,
d.TaxDeductibleAmount,
d.NonDeductibleAmount,
d.RecipientLedgerPartner
.ShortName, // Field
donorMayKnowRecipient
? d.RecipientPartner
.ShortName
: null, // Recipient
d.MotivationDetail
.Description);
}
Business Rule Analysis
Given: A receipt is being generated for a donor whose gift detail rows include destination/recipient differentiation
When: The receipt SQL joins p_partner twice with aliases GiftDestination and Recipient
Then: The receipt template can display the field/destination name (always shown) while keeping the recipient name optional — the template engine decides which to render based on the donor's confidentiality preferences. The audit trail retains the recipient key.
Source: csharp/ICT/Petra/Server/sql/Gift.ReceiptPrinting.GetDonationsOfDonor.sql line 27
Confidence: 0.70 (STRUCTURAL; medium-band — the dual-join idiom is unambiguous in the SQL but Sage's classifier surfaced it from a single source)
Behavioral Fidelity: Preserved at the query level via two EF Core navigation properties (RecipientLedgerPartner and RecipientPartner). The receipt-rendering DTO carries both names; the template engine decides which to render. The dual-join is a privacy primitive that should be flagged for the security review of receipt rendering — the new system must replicate the legacy's destination-only-vs-destination-plus-recipient rendering choice based on donor preferences.
9.7 Business Rule Traceability
| Rule ID | Rule Name | Sage Entity Name(s) | Source File | Lines | Confidence | Category |
|---|---|---|---|---|---|---|
| BR-GFT-001 | Sequential Gift Batch Numbering | GiftBatchSequentialNumbering (0.95); merged with GiftBatchConsecutiveNumbering (0.75) | Gift.Batch.cs |
111 | 0.95 | Calculation |
| BR-GFT-002 | Gift Batch Effective Date Period Validation | GiftBatchFinancialPeriodValidation (0.90, convergent across 3 clusters) | Gift.Batch.cs |
114 | 0.90 | Validation |
| BR-GFT-003 | Tax-Deductible Recalculation on Percentage Change | TaxDeductibilityComplianceCalculation (0.88, merged from 4 entities) | Gift.TaxDeductiblePct.cs |
138 | 0.88 | Calculation |
| BR-GFT-004 | Tax-Deductible Receipt Dual-Flag Eligibility | TaxDeductibleReceiptEligibility (0.87); merged with GiftReceiptEligibilityValidation (0.70) | Gift.ReceiptPrinting.GetDonationsOfDonor.sql, methodofgiving.xml |
37, 48 | 0.87 | Validation |
| BR-GFT-005 | Posted-Batch-Only Financial Reporting (Posted-Batch Immutability) | PostedBatchFinancialIntegrityRule (0.86, convergent across 2 clusters) | methodofgiving.xml |
33 | 0.86 | State Transition |
| BR-GFT-006 | Modified-Detail Flag Blocks Duplicate Reversals | GiftReversalDuplicationPrevention (0.85, merged from 3 entities) | Gift.Adjustment.cs |
202 | 0.85 | State Transition |
| BR-GFT-007 | German Banking Code Gift Detection | GiftTransactionDetectionRule (0.85, convergent across 2 clusters) | ImportFromMT940.cs, ImportFromCAMT.cs |
99, 193 | 0.85 | Workflow |
| BR-GFT-008 | Annual Receipt Default Date Range = Previous Calendar Year | AnnualReceiptDateRange (0.85); merged with AnnualReceiptDateRangeDerivation (0.50) | PrintAnnualReceipts.js |
11 (Sage cited 25; license header offset) | 0.85 | Calculation |
| BR-GFT-009 | Multi-Currency Three-Amount Tracking | MultiCurrencyGiftProcessingRule (0.83, convergent across 2 clusters) | Gift.ReceiptPrinting.GetDonationsOfDonor.sql, ICH.HOSAReportGiftSummary.sql |
7, 26 | 0.83 | Calculation |
| BR-GFT-010 | Tax-Deductible Percentage Bounds 0..100% | TaxDeductibleValidation (0.80) | TaxDeductibility.cs |
50 | 0.80 | Validation |
| BR-GFT-011 | Gift Import Default Values | GiftProcessingDefaultsRule (0.80, convergent across 2 entities) | ImportExport.cs |
514 | 0.80 | Validation |
| BR-GFT-012 | Gift Composite-Key Hierarchy Integrity | GiftTransactionHierarchyIntegrity (0.77) | Gift.Queries.ExtractDonorByAmount.sql |
16 | 0.77 | Validation |
| BR-GFT-013 | Motivation Detail Active-Status Default | MotivationDetailActivationRule (0.77, convergent across 2 entities) | Upgrade201911_201912.cs, Gift.gui.tools.cs |
48, 115 | 0.77 | State Transition |
| BR-GFT-014 | Partner-Class-Driven Motivation Defaulting | MotivationDetailAssignmentByPartnerClass (0.70); folded with UnitTypeMotivationMapping (0.70) | Gift.gui.tools.cs |
105, 132 | 0.70 | Workflow |
| BR-GFT-015 | Recurring-Gift SEPA Mandate Tracking | RecurringDonationFrequencyScheduling (0.70); merged with SEPABankingComplianceIntegration (0.65) | Upgrade202206_202207.cs, GenerateDonors.cs |
66, 97 | 0.70 | Workflow |
| BR-GFT-016 | Confidential Gift Privacy via Partner Double-Join | ConfidentialGiftPrivacyHandling (0.70) | Gift.ReceiptPrinting.GetDonationsOfDonor.sql |
27 | 0.70 | Authorization / Privacy |
File paths in this section refer to repository-relative paths under the OpenPetra source tree. Sage's Line References field stores qualified paths for most references; basename-only references were resolved via search_files before citation. The single line-anchor drift (PrintAnnualReceipts.js — Sage cited line 25, actual is line 11) is documented above; the file's GPL header offsets the citation by 14 lines. All other 14 cited file:line references were verified exactly via get_file_content_in_range.
9.8 Rule Validation Strategy
Unit Testing Approach
Each business rule maps to an xUnit test fixture in either GiftProcessingService.Tests or GiftReceiptingService.Tests. Validation rules (BR-GFT-002, BR-GFT-010, BR-GFT-011, BR-GFT-012, plus filter rules BR-GFT-004) are tested by exercising the corresponding domain validator / value object / LINQ predicate with both compliant and non-compliant inputs and asserting expected exception types and HTTP problem codes. Calculation rules (BR-GFT-001, BR-GFT-003, BR-GFT-008, BR-GFT-009) are tested with parametric inputs covering precision-edge cases (multi-currency rounding, percentage = 0/100/null, year boundaries). State-transition rules (BR-GFT-005, BR-GFT-006, BR-GFT-013) are tested with in-memory-database fixtures asserting that posted-batch mutations throw, modified-detail reversals abort, and motivation creation defaults to active. Workflow rules (BR-GFT-007, BR-GFT-014, BR-GFT-015) are tested via service-level fixtures with seeded reference data.
Integration Testing Approach
Integration tests verify rule interactions across the gift lifecycle: bank-statement import (BR-GFT-007 detection) → gift batch creation (BR-GFT-001 numbering, BR-GFT-002 period) → gift detail entry (BR-GFT-014 motivation defaulting, BR-GFT-010 percentage bounds) → tax-deductible adjustment (BR-GFT-003 recalculation, BR-GFT-006 idempotence) → batch posting → annual-receipt generation (BR-GFT-004 eligibility, BR-GFT-008 date range, BR-GFT-016 confidentiality). The test suite uses Testcontainers-hosted PostgreSQL with the existing gift-domain schema seeded from a known-good donation fixture and Service Bus emulator for the receipt-batch / SEPA-export queue interactions.
Behavioral Equivalence Approach (Golden Master)
For the duration of coexistence, the modernized gift services run in a side-by-side configuration with the legacy TGiftTransactionWebConnector. A scheduled equivalence harness submits a synthetic request set (create batch, enter gifts, adjust tax-deductible-pct, post batch, generate receipts, import bank statement, match transactions) to both stacks and compares: (1) success/failure outcomes, (2) HTTP-equivalent status codes (legacy verification-result severity mapped to RFC 7807 problem types), (3) every amount column on every gift detail row to four decimal places, and (4) the rendered annual-receipt PDFs by SHA-256 of canonicalised text content. The two highest-impact rules (BR-GFT-004 receipt eligibility, BR-GFT-005 posted-batch immutability) have golden-master tests required per the target-architecture handoff's testing decision — behavioral divergence on either is a release blocker.
Behavioral Fidelity Summary
| Total Rules | 16 distinct (deduplicated from 24 raw Sage entities — 8 same-rule overlaps merged) |
| High-Confidence (≥ 0.75) | 13 rules (BR-GFT-001 through BR-GFT-013) |
| Medium-Confidence (0.50–0.74) | 3 rules (BR-GFT-014, BR-GFT-015, BR-GFT-016) — all at exactly 0.70 |
| Lower-Confidence (< 0.50) | 0 retained (3 entities at 0.45–0.47 excluded as out-of-slice or test-anchored) |
| Confidence Score Range | 0.70 – 0.95 (mean 0.818) |
| Rules with Direct Mapping | 14 (all rules except BR-GFT-007 has codes externalised to config and BR-GFT-010 has rejection-vs-clamp tightening) |
| Rules Requiring Product-Owner Review | 3 — BR-GFT-007 (banking-code seed list), BR-GFT-010 (silent-clamp vs reject), BR-GFT-014 (unit-type coverage), plus BR-GFT-015 (mandate-reference format) |
| Source Files Verified | 13 distinct files (10 C#, 2 SQL, 1 JS) — 14 of 15 line refs exact, 1 single-digit drift (license header offset) |
| Testing Strategy | xUnit unit + Testcontainers integration (Postgres + Service Bus emulator) + scheduled side-by-side equivalence during coexistence; golden-master required for BR-GFT-004 receipt eligibility and BR-GFT-005 posted-batch immutability |
All 16 distinct business rules have been mapped to .NET 8 + Angular 18 translations with formal Given-When-Then specifications. The two headline rules — BR-GFT-004 (receipt dual-flag eligibility, the load-bearing tax-receipt contract) and BR-GFT-005 (posted-batch immutability, the load-bearing financial-integrity contract) — are golden-master-test gated. Three rules carry deliberate modernization changes that should be confirmed with the product owner before code generation: BR-GFT-007 (externalised banking-code config), BR-GFT-010 (reject-vs-clamp on out-of-bounds tax pct), and BR-GFT-014 (unit-type coverage gap). The remaining 13 rules preserve legacy behavior exactly.
10. Data Mapping Strategy
This section documents the data-tier transformation for the Finance — Gift Processing slice. The headline is unusual for a modernization plan: the gift-domain schema is preserved end-to-end. Petra's eight gift-domain 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) carry years of posted financial transactions and are the system of record for downstream finance reporting. The migration does not touch them. Six small operational tables are introduced — render_job, rendered_receipt, sepa_export_file, bank_import_upload, bank_import_line, idempotency_keys — not to remodel the financial domain but to give the queued-worker pattern (introduced in Section 4 and shown in Section 8.3) and the strangler-fig coexistence pattern the job-tracking, provenance, and idempotency rows they require. The PostgreSQL engine itself is preserved.
a_gift_batch, a_gift, or a_gift_detail today, that same query continues to work unchanged after the slice ships.
10.1 Legacy Data Architecture
The Gift Processing subsystem reads from and writes to eight gift-domain tables, all defined in db/petra.xml (the project-wide canonical schema, 24,821 lines) and projected into a typed dataset called GiftBatchTDS. The dataset is defined declaratively in csharp/ICT/Petra/Shared/lib/MFinance/data/Finance.Gift.TypedDataSets.xml (80 lines) and code-generated at build time into a strongly-typed C# class hierarchy by TDataDefinitionParser. The generated class participates in transactional reads (db.ReadTransaction) and writes (SubmitChanges per-table cascading calls) at every TGiftTransactionWebConnector entry point. All data lives in the same PostgreSQL instance; there is no fixed-length-file storage anywhere in the path.
erDiagram
A_LEDGER ||--o{ A_GIFT_BATCH : "owns numbered batches"
A_GIFT_BATCH ||--o{ A_GIFT : "contains gifts"
A_GIFT ||--o{ A_GIFT_DETAIL : "splits into detail lines"
A_GIFT_DETAIL }o--|| A_MOTIVATION_DETAIL : "categorised by (composite FK ledger+group+detail)"
A_MOTIVATION_DETAIL }o--|| A_MOTIVATION_GROUP : "in group (composite FK ledger+group)"
A_GIFT }o--|| P_PARTNER : "donor (p_donor_key_n)"
A_GIFT_DETAIL }o--|| P_PARTNER : "recipient (p_recipient_key_n)"
A_RECURRING_GIFT_BATCH ||--o{ A_RECURRING_GIFT : "contains recurring gifts"
A_RECURRING_GIFT ||--o{ A_RECURRING_GIFT_DETAIL : "splits into detail lines"
A_RECURRING_GIFT_DETAIL }o--|| A_MOTIVATION_DETAIL : "categorised by (composite FK)"
A_RECURRING_GIFT }o--|| P_PARTNER : "donor (p_donor_key_n)"
A_GIFT_BATCH {
int a_ledger_number_i PK
int a_batch_number_i PK
varchar a_batch_description_c "X(40)"
varchar a_batch_status_c "Unposted|Posted|Cancelled — see deviation in 10.4"
int a_batch_period_i "1..20"
int a_batch_year_i
date a_gl_effective_date_d
varchar a_currency_code_c "X(8)"
decimal a_batch_total_n "NUMERIC(24,10)"
decimal a_hash_total_n "NUMERIC(24,10)"
decimal a_exchange_rate_to_base_n "NUMERIC(24,10)"
varchar a_bank_cost_centre_c "X(12)"
varchar a_bank_account_code_c "X(9)"
varchar a_gift_type_c "X(8) default Gift"
varchar a_method_of_payment_code_c "X(8)"
int a_last_gift_number_i
date s_modification_date_d
}
A_GIFT {
int a_ledger_number_i PK
int a_batch_number_i PK
int a_gift_transaction_number_i PK
bigint p_donor_key_n FK
varchar a_method_of_giving_code_c
varchar a_method_of_payment_code_c
varchar a_receipt_letter_code_c
boolean a_print_receipt_l
boolean a_first_time_gift_l
boolean a_link_to_previous_gift_l
boolean a_admin_charge_l
boolean a_restricted_l
date a_date_entered_d
varchar a_reference_c "X(10)"
}
A_GIFT_DETAIL {
int a_ledger_number_i PK
int a_batch_number_i PK
int a_gift_transaction_number_i PK
int a_detail_number_i PK
bigint p_recipient_key_n FK
bigint a_recipient_ledger_number_n
varchar a_motivation_group_code_c FK
varchar a_motivation_detail_code_c FK
decimal a_gift_transaction_amount_n "NUMERIC(24,10)"
decimal a_gift_amount_n "base ledger currency"
decimal a_gift_amount_intl_n "international currency"
decimal a_tax_deductible_pct_n "NUMERIC(5,2)"
boolean a_confidential_gift_flag_l "lives here, not on a_gift"
boolean a_modified_detail_l
boolean a_tax_deductible_l
}
A_MOTIVATION_DETAIL {
int a_ledger_number_i PK
varchar a_motivation_group_code_c PK
varchar a_motivation_detail_code_c PK "X(8) — width is the source of the seed-data 'MED-EQUIPT' overflow noted in 10.7"
varchar a_account_code_c "default GL account"
varchar a_cost_centre_code_c "default cost centre"
boolean a_tax_deductible_l
boolean a_membership_l
boolean a_sponsorship_l
boolean a_worker_support_l
}
A_RECURRING_GIFT_BATCH {
int a_ledger_number_i PK
int a_batch_number_i PK
varchar a_currency_code_c "X(8)"
varchar a_batch_description_c "X(40) nullable — unlike a_gift_batch which is notnull"
decimal a_batch_total_n "NUMERIC(24,10)"
}
A_RECURRING_GIFT {
int a_ledger_number_i PK
int a_batch_number_i PK
int a_gift_transaction_number_i PK
bigint p_donor_key_n FK
varchar a_reference_c "X(8) — narrower than a_gift.a_reference_c X(10)"
varchar a_sepa_mandate_reference_c "X(35) — donor-key + YYYYMMDD; lives HERE not on the batch row"
date a_sepa_mandate_given_d
varchar a_charge_status_c "X(10)"
date a_last_debit_d
int a_debit_day_i
boolean a_active_l
}
A_RECURRING_GIFT_DETAIL {
int a_ledger_number_i PK
int a_batch_number_i PK
int a_gift_transaction_number_i PK
int a_detail_number_i PK
bigint p_recipient_key_n FK
decimal a_gift_amount_n
boolean a_confidential_gift_flag_l
date a_start_donations_d
date a_end_donations_d
}
Legacy ER diagram — the eight gift-domain tables consumed by GiftBatchTDS, sourced from db/petra.xml and reproduced from the schema-decisions handoff. All eight tables are preserved unchanged in the target. Three relationships worth highlighting: (1) the SEPA mandate columns (a_sepa_mandate_reference_c, a_sepa_mandate_given_d) live on a_recurring_gift — the per-donor row — not on a_recurring_gift_batch; (2) a_confidential_gift_flag_l lives on the per-line a_gift_detail table, not on the donor-side a_gift header; (3) the FK from a_gift_detail to a_motivation_detail is a composite three-column key (a_ledger_number_i, a_motivation_group_code_c, a_motivation_detail_code_c), not the single-column reference a flat reading might suggest.
10.2 Target Data Architecture
The target preserves the PostgreSQL engine, the partner-key identity space (p_partner.p_partner_key_n), the four legacy reference-data tables that gift services consume read-only (a_ledger, a_currency, a_account, p_partner), and every column of every gift-domain table. The data-tier delta is therefore narrow: six operational tables that exist only because the queued-worker pattern and the strangler-fig coexistence pattern need them.
| Table | Owner | Why it exists | Lives because of… |
|---|---|---|---|
render_job |
gift-receipting-service | Tracks queued / running / completed / failed batch jobs (annual-receipt rendering, SEPA export, ICH export). Each job row owns the worker's progress, retry count, and dead-letter status. | Synchronous-render-on-request retired (Section 8.3) ⇒ jobs need persistence outside the request. |
rendered_receipt |
gift-receipting-service | Provenance row per rendered annual-receipt PDF: SHA-256 hash, donor partner key, ledger number, tax-year date range, immutable-until timestamp. Binary lives in Azure Blob Storage under immutability policy. | Filesystem-temp-file pattern retired (Section 4 handoff) ⇒ tax-audit retention needs a queryable provenance surface. |
sepa_export_file |
gift-receipting-service | Provenance row per SEPA Direct Debit XML file: collection date, mandate count, total amount in ledger currency, originating render_job reference. Binary in Blob. |
SEPA file delivery is asynchronous ⇒ the artefact must be auditable after the file leaves the system. |
bank_import_upload |
gift-receipting-service | Captures one bank-statement upload (CAMT / MT940 / CSV / ZIP), with status transitions Uploaded → Parsing → Parsed → MatchingComplete. Raw upload retained in Blob for audit. |
Session-state-driven bank-import flow retired ⇒ upload state must survive worker restarts. |
bank_import_line |
gift-receipting-service | Parsed transaction lines with match status (Unmatched / AutoMatched / UserMatched / Rejected) and an optional composite FK fragment to the matched a_gift_detail row. |
Manual matching surface (Section 7.5) needs a persistent row per pending line, with revisable match decisions. |
idempotency_keys |
gift-processing-service | Per-request idempotency-token storage for the X-Idempotency-Key header. Stores method, path, body hash, response status, response body, and TTL expiry. |
Strangler-fig coexistence pattern (Section 11) needs at-most-once semantics across legacy / new dual paths. |
Note — the FK fragment from bank_import_line to a_gift_detail is the only point where a new operational table references a legacy gift-domain row. It is recorded as a four-column nullable reference (matched_ledger_number, matched_batch_number, matched_gift_transaction_number, matched_detail_number); the V1 handoff records single-column FKs only, so the composite-key constraint is materialised at deploy time. The reverse direction does not exist: posted gift-detail rows are immutable (BR-GFT-005) and never deleted, so the SET-NULL semantics on rollback are defensive belt-and-braces, not a routinely-exercised path.
For the complete target data model ER diagram showing all entity relationships, see Section 4.4: Data Model.
10.3 The Preservation Decision
Why does the gift-domain schema stay? Three reasons compound:
-
It is the system of record for years of posted batches.
a_gift_batch,a_gift, anda_gift_detailhold every donation Petra has processed since deployment, including batches whosea_batch_status_c='Posted'rows are immutable per BR-GFT-005. Migrating the data shape would mean migrating the data, and migrating the data of a financial system of record is an order-of-magnitude harder problem than migrating the code that reads it — reconciliation, dual-write windows, posted-batch-checksum validation, and tax-authority audit consistency would all be in scope. The slice does not require any of that to deliver the user-visible improvements documented in Sections 7 and 8. -
Downstream finance reporting reads the same tables. Out of the eight gift-domain tables, six are also consumed by GL posting (
a_transaction/a_journalwrites derive froma_gift_detail's motivation defaults), by donor-history reports, by tax-receipt aggregations, and by the cross-reference report queries listed in Section 4.4's indexing additions. Renaming a column or splitting a table would force rewrites in code paths the slice does not own. Preserving the schema means the slice changes nothing for those readers. -
The case for redesign is weak. The schema is well-normalised, FK-constrained, partner-key-aligned, and supports the multi-currency three-amount-per-line pattern (
a_gift_transaction_amount_nin donor currency,a_gift_amount_nin ledger base currency,a_gift_amount_intl_nin the ledger's international rollup currency, allNUMERIC(24,10)) that the domain genuinely requires. There is no obvious schema-design improvement that would justify the migration cost. The legacy code that reads the schema (typed datasets, code-gen pipeline, RPC web-connectors) has well-known modernisation wins (Sections 7, 8); the legacy schema does not.
The preservation decision means that for the eight gift-domain tables there is no DDL change, no data migration, no shadow-write phase, and no read-from-old / write-to-new dual-path window. The only thing that changes about how those tables are touched is the code that touches them — that is the subject of section 10.4 and the entire body of Section 8.
10.4 The Code-Generation Pipeline Retirement
Petra's legacy data-access path is a code-generation pipeline:
flowchart LR A["db/petra.xml
(canonical schema, 24,821 lines)"] -->|TDataDefinitionParser
NAnt build target| B["Generated DAL classes
(per-table CRUD + access layer)"] C["Finance.Gift.TypedDataSets.xml
(80 lines)"] -->|TDataDefinitionParser
+ TTypedDataSetGenerator| D["GiftBatchTDS.Generated.cs
+ GiftBatchTDSAccess.cs
(strongly-typed dataset)"] A -.references.-> C B --> E["TGiftTransactionWebConnector
+ TGiftBatchFunctions
(business logic reads/writes via TDS)"] D --> E
Legacy data-access pipeline. petra.xml defines the schema once; the typed-dataset XML names which tables a given subsystem cares about plus a few subsystem-specific custom fields; the parser plus typed-dataset generator together emit C# classes at build time; web-connectors compose business logic on top of the typed dataset.
For the gift slice, every leg of this pipeline is retired in favour of EF Core 8 code-first — but the schema itself is preserved. EF Core does not rewrite the tables; it provides a different way for the new C# code to read and write the same rows.
| Legacy artefact | Target replacement | Generated by… |
|---|---|---|
db/petra.xml (gift-table sections only) consumed by TDataDefinitionParser |
EF Core 8 entity classes with Fluent-API configuration mapping each entity to the existing physical tables and columns | Hand-written first; future EF Core scaffolding (dotnet ef dbcontext scaffold) optional |
Finance.Gift.TypedDataSets.xml consumed by TTypedDataSetGenerator |
No replacement — EF Core's DbContext + LINQ + projection DTOs subsume what the typed dataset provided (table membership, custom calculated fields, multi-table read graphs) |
Retired entirely |
GiftBatchTDS.Generated.cs + GiftBatchTDSAccess.cs |
GiftDbContext, AGiftBatch / AGift / AGiftDetail / etc. entity classes, plus per-batch DTO classes for API responses |
Hand-written EF Core entities; DTOs hand-written or AutoMapper-generated |
Custom typed-dataset fields (GiftBatchTotal, DonorName, RecipientDescription, RecipientField, etc.) |
LINQ projection into response DTOs that aggregate or join on demand; donor-name and recipient-description joins move into the API-response shape, not the storage shape | Hand-written DTO + projection; values computed at read time |
| NAnt code-gen target invoked by the build | No code-gen step in the gift-services build; dotnet build + GitHub Actions only |
Retired entirely for the slice; legacy zones may keep petra.xml code-gen during coexistence per Section 11 |
The schema is preserved; the way the C# code accesses it changes. Below, side-by-side: the legacy generated typed-dataset schema declaration (column 1), the existing physical PostgreSQL DDL that both the legacy code and the new code target (column 2, byte-faithful to db/petra.xml via the schema-decisions handoff), and the EF Core 8 entity that maps the new code to those existing physical tables (column 3).
a_gift_batch.a_batch_status_c. The handoff records exactly one deliberate width deviation in the entire eight-table preserved set. Petra's <tablefield> declares format="X(8)" for a_batch_status_c, but Petra's own <descr>/<initial> documents "Unposted, Posted, Cancelled" as the valid status set — and the string "Cancelled" is 9 characters, which physically cannot fit in an 8-character constraint. This is a self-contradiction in the Petra source itself, not an artefact of the migration. The handoff resolves it by widening to VARCHAR(16) with the deviation flagged in the schema-decisions record (reason: "petra-self-contradiction", claimedSourceWidth: 8, deliberateWidth: 16). The SQL block in column 2 below carries an inline -- DEVIATION: comment on the same line as the column. No other column in the preserved set deviates from Petra.
Legacy: typed-dataset XML + generated C# (RETIRED)
<!-- Finance.Gift.TypedDataSets.xml -->
<DataSet name="GiftBatchTDS">
<Table sqltable="a_ledger"/>
<Table sqltable="a_gift_batch">
<CustomField name="GiftBatchTotal"
type="Decimal"/>
</Table>
<Table sqltable="a_gift">
<CustomField name="DonorName"
type="String"/>
<CustomField name="GiftTotal"
type="Decimal"/>
</Table>
<Table sqltable="a_gift_detail">
<CustomField name="DonorKey"
type="Int64"/>
<CustomField name="DonorName"
type="String"/>
<CustomField name="DonorClass"
type="String"/>
<CustomField name="DateEntered"
type="DateTime"/>
<CustomField name="RecipientDescription"
type="String"/>
// ...11 more custom fields
</Table>
<Table sqltable="a_recurring_gift_batch"/>
<Table sqltable="a_recurring_gift">
// ...same custom-field treatment
</Table>
<Table sqltable="a_recurring_gift_detail">
// ...17 custom fields
</Table>
<Table sqltable="a_motivation_group"/>
<Table sqltable="a_motivation_detail"/>
<Table sqltable="p_partner"
name="DonorPartners"/>
<Table sqltable="p_partner"
name="RecipientPartners"/>
// ...support tables
</DataSet>
// Generated at build time by
// TTypedDataSetGenerator into:
// GiftBatchTDS.Generated.cs (~thousands of LOC)
// GiftBatchTDSAccess.cs (~hundreds of LOC)
// + per-table strongly-typed
// row classes, DataTable
// subclasses, and
// SubmitChanges helpers
Source: Finance.Gift.TypedDataSets.xml (80 lines, full file). Generated dataset retired for the gift services; legacy-zone retention deferred to Section 11.
Schema: PostgreSQL DDL (PRESERVED unchanged)
-- Existing physical schema, sourced
-- byte-faithful from db/petra.xml via
-- schema-decisions-002.json (cycle 23).
-- All 19 columns; column order matches
-- Petra source order. NO DDL CHANGES.
CREATE TABLE a_gift_batch (
a_ledger_number_i INTEGER NOT NULL DEFAULT 0,
a_batch_number_i INTEGER NOT NULL DEFAULT 0,
a_batch_description_c VARCHAR(40) NOT NULL,
s_modification_date_d DATE DEFAULT CURRENT_DATE,
a_hash_total_n NUMERIC(24,10) DEFAULT 0,
a_batch_total_n NUMERIC(24,10) DEFAULT 0,
a_bank_account_code_c VARCHAR(9) NOT NULL,
a_last_gift_number_i INTEGER DEFAULT 0,
a_batch_status_c VARCHAR(16) DEFAULT 'Unposted',
-- DEVIATION: source X(8) -> handoff 16;
-- petra-self-contradiction (see callout
-- above): 'Cancelled' is 9 chars but the
-- <field> declares X(8). Widened so the
-- constraint matches the documented enum.
a_batch_period_i INTEGER NOT NULL DEFAULT 0,
a_batch_year_i INTEGER NOT NULL,
a_gl_effective_date_d DATE NOT NULL DEFAULT CURRENT_DATE,
a_currency_code_c VARCHAR(8) NOT NULL,
a_exchange_rate_to_base_n NUMERIC(24,10) NOT NULL DEFAULT 0,
a_bank_cost_centre_c VARCHAR(12) NOT NULL,
a_gift_type_c VARCHAR(8) NOT NULL DEFAULT 'Gift',
a_method_of_payment_code_c VARCHAR(8),
PRIMARY KEY(a_ledger_number_i,
a_batch_number_i),
FOREIGN KEY(a_ledger_number_i)
REFERENCES a_ledger(a_ledger_number_i),
FOREIGN KEY(a_currency_code_c)
REFERENCES a_currency(a_currency_code_c)
);
-- Existing indexes preserved as-is.
-- date_effective, period_effective.
-- One additive index from § 4.4:
CREATE INDEX ix_gift_batch_landing
ON a_gift_batch(
a_ledger_number_i,
a_batch_status_c,
a_gl_effective_date_d);
-- (supports unposted-batches landing
-- query and field-adjustment Posted-
-- only filter; pure additive change)
Source: db/petra.xml lines 11889–12068, transcribed via the schema-decisions handoff. All 19 columns are reproduced in source order; widths and precisions match Petra exactly except for the one declared deviation flagged inline. The slice does not modify any of this; the one new index is additive and concurrent-create-safe.
Application Model: EF Core 8 entity (NEW code, same table)
// Entities/AGiftBatch.cs
public sealed class AGiftBatch
{
public int LedgerNumber { get; set; }
public int BatchNumber { get; set; }
public string BatchDescription { get; set; }
= string.Empty;
public BatchStatus BatchStatus { get; set; }
public int BatchPeriod { get; set; }
public int BatchYear { get; set; }
public DateOnly GlEffectiveDate { get; set; }
public string CurrencyCode { get; set; }
= string.Empty;
public decimal ExchangeRateToBase { get; set; }
public decimal? BatchTotal { get; set; }
public decimal? HashTotal { get; set; }
public string BankAccountCode { get; set; }
= string.Empty;
public string BankCostCentre { get; set; }
= string.Empty;
public string GiftType { get; set; }
= "Gift";
public string? MethodOfPaymentCode { get; set; }
public DateOnly? ModificationDate { get; set; }
// (Petra audit on a_gift_batch is the
// single column s_modification_date_d;
// no s_created_by_c / s_modified_by_c on
// this table per Petra source.)
// Navigation
public ALedger Ledger { get; set; } = null!;
public ICollection<AGift> Gifts
{ get; } = new List<AGift>();
}
// Fluent-API mapping
public sealed class AGiftBatchConfig
: IEntityTypeConfiguration<AGiftBatch>
{
public void Configure(
EntityTypeBuilder<AGiftBatch> b)
{
// Map to the EXISTING physical table.
// Hungarian column suffixes preserved.
b.ToTable("a_gift_batch");
b.HasKey(x => new
{ x.LedgerNumber, x.BatchNumber });
b.Property(x => x.LedgerNumber)
.HasColumnName("a_ledger_number_i");
b.Property(x => x.BatchNumber)
.HasColumnName("a_batch_number_i");
b.Property(x => x.BatchDescription)
.HasColumnName("a_batch_description_c")
.HasMaxLength(40);
b.Property(x => x.BatchStatus)
.HasColumnName("a_batch_status_c")
.HasConversion<string>()
.HasMaxLength(16); // declared deviation
b.Property(x => x.CurrencyCode)
.HasColumnName("a_currency_code_c")
.HasMaxLength(8);
b.Property(x => x.BatchTotal)
.HasColumnName("a_batch_total_n")
.HasPrecision(24, 10);
b.Property(x => x.ExchangeRateToBase)
.HasColumnName("a_exchange_rate_to_base_n")
.HasPrecision(24, 10);
b.Property(x => x.BankAccountCode)
.HasColumnName("a_bank_account_code_c")
.HasMaxLength(9);
b.Property(x => x.BankCostCentre)
.HasColumnName("a_bank_cost_centre_c")
.HasMaxLength(12);
// ...remaining columns (period/year/etc.)
}
}
The C# property names use modern PascalCase; the HasColumnName mappings preserve the Hungarian-style legacy column names verbatim. HasMaxLength values match Petra: 40 for description, 8 for currency code, 9 for bank account, 12 for bank cost centre, 16 for batch status (with the declared deviation comment). HasPrecision(24,10) for monetary amounts and exchange rate match Petra's length=24 decimals=10. EF Core targets the existing rows directly; no migration is generated for this entity.
Migration Notes — a_gift_batch typed-dataset retirement
| Legacy access path | Target access path | Disposition |
|---|---|---|
GiftBatchTDS.AGiftBatch typed row class |
AGiftBatch EF Core entity (column 3) |
Hand-written; targets the same physical table; no DDL change |
GiftBatchTDSAccess.LoadByPrimaryKey(...) |
db.AGiftBatches.FindAsync(ledgerNumber, batchNumber) |
EF Core change-tracking replaces TDS row state |
SubmitChanges per-table cascading calls |
Single db.SaveChangesAsync() inside BeginTransactionAsync(IsolationLevel.Serializable) (Section 8.2) |
Concurrency level preserved; ordering managed by EF Core |
Custom field GiftBatchTotal populated by separate aggregation query |
LINQ projection: db.AGifts.Where(g => g.LedgerNumber == n && g.BatchNumber == m).SelectMany(g => g.Details).SumAsync(d => d.GiftAmount) — computed at API-response time |
Storage shape unchanged; calculation moved into the response shape |
Custom field DonorName on a_gift (legacy joined at typed-dataset load) |
DTO field populated via federated REST call to legacy MPartner (Section 4 integration boundary) | Donor identity stays with legacy MPartner; gift-services consume it read-only |
Hungarian-style column names (a_batch_description_c, a_gl_effective_date_d, etc.) |
Preserved at the column level (HasColumnName); modernised at the C# property level (BatchDescription, GlEffectiveDate) |
Per § 10.9.1 string-naming rule: legacy-shared tables keep legacy names; new code uses modern names through the EF mapping layer |
Audit columns — Petra reality. Petra's gift-domain tables do not carry the universal s_date_created_d / s_created_by_c / s_date_modified_d / s_modified_by_c / s_modification_id_t audit suffix found on some other Petra modules. a_gift_batch carries exactly one audit column — s_modification_date_d. The other seven preserved gift-domain tables carry zero. The EF Core entity reflects this: AGiftBatch.ModificationDate maps to the one audit column that actually exists; the new created_at / updated_at / created_by / updated_by universal-rule columns from § 10.9.1 are added only to the six new operational tables, never to preserved tables.
Data volume. a_gift_batch grows ~5–15K rows per ledger per year of operation; a_gift_detail 50–200K rows. A 10-year-old deployment carries on the order of 105–106 rows total. Preserve-schema means none of those rows move during the migration.
Migration approach. Zero-row, zero-DDL. The new EF Core DbContext connects to the same database as the legacy TDataBase; both can coexist on the same physical tables during the strangler-fig coexistence window (per Section 11). The cutover is a routing decision at APIM, not a data move.
Section 5 alignment. Entry 5.4.1 in the Platform Affinity Analysis classifies the schema-source-of-truth (petra.xml code-gen pipeline) as HYBRID with a preserve-schema variant: the code-gen path is retired for the slice, the schema is preserved for the whole project. This subsection is the data-tier expression of that decision.
10.5 Operational Tables (NEW)
The six operational tables exist to give the queued-worker pattern persistence outside the request thread, plus durable idempotency for the strangler-fig coexistence boundary. Five are owned by gift-receipting-service; the sixth, idempotency_keys, is owned by gift-processing-service. The interactive surfaces never write to the receipting tables and read them only for status display. They share an audit-column convention (created_at, updated_at, created_by, updated_by) per § 10.9.1.
erDiagram
RENDER_JOB ||--o{ RENDERED_RECEIPT : "produces (annual-receipt jobs)"
RENDER_JOB ||--o{ SEPA_EXPORT_FILE : "produces (sepa jobs)"
BANK_IMPORT_UPLOAD ||--o{ BANK_IMPORT_LINE : "parses into"
BANK_IMPORT_LINE }o..o| A_GIFT_DETAIL : "matches (composite-key FK fragment to legacy gift-domain row)"
RENDER_JOB {
uuid render_job_id PK
varchar job_type "AnnualReceipt|Sepa|Ich"
int ledger_number FK "to a_ledger"
varchar status "Queued|Running|Completed|Failed|DeadLettered"
int retry_count
timestamptz requested_at
timestamptz started_at
timestamptz completed_at
text failure_reason
jsonb job_payload "type-specific parameters"
varchar requested_by
}
RENDERED_RECEIPT {
uuid receipt_id PK
uuid render_job_id FK
int ledger_number FK
bigint donor_partner_key "logical FK to legacy p_partner"
int tax_year
date period_start
date period_end
varchar blob_uri "azure blob path"
varchar sha256_hash
bigint size_bytes
timestamptz rendered_at
timestamptz immutable_until
}
SEPA_EXPORT_FILE {
uuid sepa_file_id PK
uuid render_job_id FK
int ledger_number FK
date collection_date
int mandate_count
decimal total_amount "ledger currency"
varchar currency_code FK "to a_currency"
varchar blob_uri
varchar sha256_hash
timestamptz generated_at
}
BANK_IMPORT_UPLOAD {
uuid upload_id PK
int ledger_number FK
varchar file_format "CAMT|MT940|CSV|ZIP"
varchar status "Uploaded|Parsing|Parsed|MatchingComplete|Failed"
varchar blob_uri "raw upload retained"
varchar sha256_hash
bigint size_bytes
timestamptz uploaded_at
varchar uploaded_by
int line_count
int matched_count
}
BANK_IMPORT_LINE {
uuid line_id PK
uuid upload_id FK
int line_number
date value_date
varchar transaction_code "BR-GFT-007"
decimal amount
varchar currency_code FK
varchar payer_iban
varchar payer_name
text statement_text
varchar match_status "Unmatched|AutoMatched|UserMatched|Rejected"
int matched_ledger_number "nullable; FK fragment 1/4"
int matched_batch_number "nullable; 2/4"
int matched_gift_transaction_number "nullable; 3/4"
int matched_detail_number "nullable; 4/4"
timestamptz matched_at
varchar matched_by
}
IDEMPOTENCY_KEYS {
varchar idempotency_key PK
varchar request_method
varchar request_path
varchar request_body_hash
int response_status_code
jsonb response_body
timestamptz first_seen_at
timestamptz completed_at
timestamptz expires_at
}
Operational tables ER diagram (six tables). render_job is the queued-worker job-tracking table; rendered_receipt and sepa_export_file are the per-output provenance rows; bank_import_upload → bank_import_line form a parent-child pair for the bank-statement matching flow; idempotency_keys stands alone as the strangler-fig at-most-once token store. The dashed FK from bank_import_line to a_gift_detail is the only edge that crosses into the preserved gift-domain schema, and it is a four-column composite reference materialised at deploy time.
Below: the DDL and EF Core entity for render_job as the worked example. The other five operational tables follow the same shape (UUID PK, audit columns, NUMERIC for monetary, JSONB where the per-job-type payload genuinely is heterogeneous). Their full DDL is generated by Sage’s artifact-generation pipeline and ships in the migration artefacts bundle.
Legacy: NO EQUIVALENT
// In the legacy synchronous world,
// CreateAnnualGiftReceipts is a single
// 26-parameter [WebMethod] call that
// returns three out-parameters and
// writes the PDFs to a temporary
// filesystem path before streaming
// them back to the caller. There is
// NO database row representing the
// job's existence, NO retry record,
// NO dead-letter trace, NO durable
// audit. If the request times out,
// the partial work is lost and the
// caller has to start over.
//
// (See Section 8.3 for the legacy
// method signature.)
//
// The render_job table exists
// because the queued-worker pattern
// requires durable persistence of
// every job's lifecycle. There
// is nothing to migrate from
// legacy — this is a net-new
// operational entity.
NEW — no legacy equivalent. This is the queued-worker pattern's defining persistence requirement; it has no analogue in the legacy synchronous flow.
Target: PostgreSQL DDL (NEW)
-- Migration: 20260501_AddRenderJob.sql
CREATE TABLE render_job (
render_job_id UUID
PRIMARY KEY
DEFAULT gen_random_uuid(),
job_type VARCHAR(32) NOT NULL
CHECK(job_type IN
('AnnualReceipt',
'Sepa',
'Ich')),
ledger_number INTEGER NOT NULL
REFERENCES a_ledger(a_ledger_number_i),
status VARCHAR(20) NOT NULL
CHECK(status IN
('Queued', 'Running',
'Completed', 'Failed',
'DeadLettered')),
retry_count INTEGER NOT NULL
DEFAULT 0,
requested_at TIMESTAMPTZ NOT NULL
DEFAULT CURRENT_TIMESTAMP,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
failure_reason TEXT,
job_payload JSONB NOT NULL,
requested_by VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL
DEFAULT CURRENT_TIMESTAMP,
created_by VARCHAR(64) NOT NULL,
updated_at TIMESTAMPTZ NOT NULL
DEFAULT CURRENT_TIMESTAMP,
updated_by VARCHAR(64) NOT NULL
);
CREATE INDEX ix_render_job_pickup
ON render_job(status, requested_at)
WHERE status IN
('Queued', 'Running');
CREATE INDEX ix_render_job_history
ON render_job(ledger_number,
requested_at DESC);
Generated by dotnet ef migrations add AddRenderJob from the RenderJob entity in column 3.
Application Model: EF Core 8 entity
// Entities/RenderJob.cs
public sealed class RenderJob
{
public Guid RenderJobId { get; set; }
= Guid.NewGuid();
public JobType JobType { get; set; }
public int LedgerNumber { get; set; }
public JobStatus Status { get; set; }
= JobStatus.Queued;
public int RetryCount { get; set; }
public DateTimeOffset RequestedAt { get; set; }
public DateTimeOffset? StartedAt { get; set; }
public DateTimeOffset? CompletedAt { get; set; }
public string? FailureReason { get; set; }
public JsonDocument JobPayload { get; set; }
= null!;
public string RequestedBy { get; set; }
= string.Empty;
// Audit (universal rule, § 10.9.1)
public DateTimeOffset CreatedAt { get; set; }
public string CreatedBy { get; set; }
= string.Empty;
public DateTimeOffset UpdatedAt { get; set; }
public string UpdatedBy { get; set; }
= string.Empty;
// Navigation
public ICollection<RenderedReceipt> Receipts
{ get; } = new List<RenderedReceipt>();
public ICollection<SepaExportFile> SepaFiles
{ get; } = new List<SepaExportFile>();
}
public sealed class RenderJobConfig
: IEntityTypeConfiguration<RenderJob>
{
public void Configure(
EntityTypeBuilder<RenderJob> b)
{
b.ToTable("render_job");
b.HasKey(j => j.RenderJobId);
b.Property(j => j.JobType)
.HasConversion<string>()
.HasMaxLength(32);
b.Property(j => j.Status)
.HasConversion<string>()
.HasMaxLength(20);
b.Property(j => j.JobPayload)
.HasColumnType("jsonb");
b.HasIndex(j => new
{ j.Status, j.RequestedAt })
.HasFilter("status IN ('Queued','Running')");
}
}
Modern naming throughout (no Hungarian suffixes — new table per § 10.9.1). Status/JobType stored as text via EF Core's enum conversion so the CHECK constraint is human-readable.
Migration Notes — operational tables
NEW — no legacy equivalent. All six operational tables exist to support the queued-worker pattern (Section 8.3) and the strangler-fig idempotency boundary (Section 11). They have no rows in the legacy database; nothing migrates into them.
Field naming. Modern snake_case throughout (render_job_id, not a_render_job_uuid). These are net-new tables and per § 10.9.1 they do not inherit the Hungarian-style legacy convention.
Reference to legacy tables. Three FK families cross from operational tables into the preserved gift-domain or partner schema: render_job.ledger_number → a_ledger, rendered_receipt.donor_partner_key → p_partner (logical-only during coexistence, no enforced FK across the strangler-fig boundary; promoted to enforced once MPartner migrates), and the four-column composite FK fragment from bank_import_line to a_gift_detail. None of these change the legacy tables.
Numeric precision. Monetary fields on operational tables (sepa_export_file.total_amount, bank_import_line.amount) use NUMERIC(15,2); this is the new-domain choice, not a legacy match. The preserved gift-domain monetary columns use NUMERIC(24,10) (Petra's length=24 decimals=10) and are unchanged.
Indexing. Worker-pickup index on render_job(status, requested_at) filtered to Queued|Running rows keeps the index hot and small (the table grows monotonically; only the live tail is queried). Manual-match queue index on bank_import_line(upload_id, match_status) serves the user-matching surface. Idempotency-expiry index on idempotency_keys(expires_at) serves the TTL sweep.
Blob layout. Per Section 4: receipts/{ledger_number}/{year}/{donor_partner_key}/{receipt_id}.pdf (immutability policy), sepa-exports/{ledger_number}/{collection_date}/{sepa_file_id}.xml, bank-imports/{ledger_number}/{upload_date}/{upload_id}.{ext}. The blob_uri column on each provenance row is the canonical pointer.
Section 5 alignment. Entry 5.2.1 (synchronous-pipeline-for-batch-workloads, classification ELIMINATE) is the platform-affinity case for the receipting tables; entry 5.6.x (idempotency for dual-traffic coexistence) covers idempotency_keys. Together they are the data-tier expression of the queued-worker and strangler-fig decisions.
10.6 ER Diagrams — Together
The two diagrams in § 10.1 (preserved gift-domain schema, eight tables plus four read-only-reference targets) and § 10.5 (operational tables, six entities) compose into a single picture of the data tier the slice produces. The seam between them is narrow: three FK families from operational tables into the preserved gift-domain / partner / ledger schema, and zero references in the reverse direction. The preserved schema has no awareness of the operational tables' existence.
Because the preserved schema is reproduced verbatim in § 10.1 and the operational tables in § 10.5, no third combined diagram is rendered — a single 18-table ER diagram would obscure the architectural point that the two halves are deliberately separate. Section 4.4's data-model figure carries the unified target view at the architecture level, with FK arrows summarised rather than enumerated; this Section 9 layout intentionally complements it by showing the per-half detail that Section 4 abstracts away.
10.7 Migration Mechanics
The slice's data-tier migration is the smallest of any modernisation pattern in the project: modify zero existing tables; add six operational tables.
| Migration step | What happens | Cutover risk |
|---|---|---|
Apply 20260501_AddRenderJob.sql + five siblings + the additive index on a_gift_batch |
Six CREATE TABLE + audit-column triggers + per-table CREATE INDEX + one additive CREATE INDEX CONCURRENTLY on a_gift_batch; p_trgm GIN index on partner-name fields per Section 4.4 |
Low — pure additive DDL; concurrent index creation does not block writes; reversible by DROP TABLE + DROP INDEX |
| Backfill operational tables | No backfill required. Operational tables start empty and accumulate rows as new jobs run. | None — nothing to validate |
| Dual-write window | Not needed for any table. The new EF Core DbContext writes to a_gift_batch / a_gift / a_gift_detail using the same DDL as the legacy TDataBase; transactions serialise at the row level. Both code paths can write concurrently during coexistence, with the idempotency_keys table preventing duplicate effects from dual-traffic replays. |
Concurrency is a code-tier concern (Section 8.2 transaction isolation) and an idempotency-token concern, not a data-tier concern |
| Seed-data validation | Pre-cutover smoke run: every preserved-table column is round-tripped against the handoff-declared widths. The known seed-data overflow (a_motivation_detail.a_motivation_detail_code_c VARCHAR(8) vs the seed value "MED-EQUIPT" 10 chars) is caught here, not in production. |
Low — catches the one known seed-data hazard; deferred fix per Plan Section E |
| Cutover | A routing-table change at APIM moves traffic from the legacy .asmx endpoint to the new ASP.NET Core 8 endpoints (Section 11). No data is moved. |
Low — instant routing flip with same-DB rollback if the new endpoints misbehave |
| Rollback | Reverse the APIM routing flip; the legacy code continues to read and write the same rows it always has. Operational tables keep accumulating rows for any in-flight jobs but are otherwise inert. | Trivial — no data un-migration step |
Section 11 owns the full coexistence and cutover plan including the per-endpoint strangler-fig schedule, dual-traffic monitoring, and the GL-write reliability story (the one place where new services write to legacy — via federated REST to legacy MFinance, not directly to the database).
10.8 Petra-XML Code Generation Pipeline — Retired for the Slice
Section 10.4 covered the artefact-level retirement; this subsection covers what happens to the rest of the pipeline machinery for the gift slice's surface and what stays alive elsewhere.
| Pipeline component | Status for gift slice | Status for legacy zones during coexistence |
|---|---|---|
db/petra.xml (canonical schema source) |
Not consulted at runtime by gift services; EF Core entities are the source of truth for the slice's database access. Consulted at artifact-generation time via the schema-decisions handoff to verify byte-fidelity of preserved DDL. | Continues as canonical schema source for MPartner, MFinance (non-gift), MConference, MPersonnel, etc. Schema migrations for those zones still flow through it |
Finance.Gift.TypedDataSets.xml |
Retired. The file is left in the repo for the legacy code paths that still reference GiftBatchTDS during coexistence; once those paths cut over, the file is deleted. |
Read-only artefact during coexistence; not regenerated |
TDataDefinitionParser + TTypedDataSetGenerator NAnt task |
Not invoked by gift-services build (no NAnt; dotnet build only) |
Continues to generate code for legacy zones; runs from the existing legacy CI |
Generated GiftBatchTDS.Generated.cs + GiftBatchTDSAccess.cs |
Not referenced by gift services; the generated files persist in the legacy assembly until coexistence ends | Legacy .asmx endpoints still link against them |
EF Core 8 migrations + DbContext |
NEW — produces only the six operational-table migrations and the one additive index; never produces a migration that touches a gift-domain column | Not used by legacy zones |
The orchestrator must therefore know which tables are owned by EF Core (the six operational tables) and which are mapped-but-not-owned (the eight gift-domain tables, plus the four legacy-shared read-only-reference tables a_ledger, a_currency, a_account, p_partner). The distinction matters for migration-script generation: EF Core migrations should be authored only for the owned set; the mapped-but-not-owned set is configured as existing in the DbContext via Fluent API, and the generated migration should never propose DDL changes against them. A unit test in the build verifies dotnet ef migrations add CheckGiftDomainStable produces an empty migration — if it ever does not, the configuration has drifted and the build fails. Section 11 owns the multi-slice cutover plan.
10.9 Schema Migration Design Rules
These design rules govern every data-tier decision the artifact-generation workflow makes. They are split into universal rules (apply regardless of target database engine) and engine-specific rules (implementation details for PostgreSQL, the chosen and preserved engine for Petra). PostgreSQL is fixed for this engagement; the engine-specific table is included so this contract travels to a future Cosmos / DynamoDB target without re-deriving the universal rules.
10.9.1 Universal Rules (engine-agnostic)
| Decision | Rule | Rationale | Example |
|---|---|---|---|
| Field naming | In NEW operational tables, use modern snake_case without Hungarian-type suffixes (render_job_id, not a_render_job_uuid). In PRESERVED gift-domain tables, the legacy column names are unchanged at the database; the EF Core entity exposes modern PascalCase via HasColumnName mappings (BatchDescription ↔ a_batch_description_c). |
Modern .NET / Postgres convention is suffix-free; renaming preserved gift-domain tables would break legacy code paths and downstream finance reports that still author / read them. | a_gift_batch.a_batch_description_c (preserved at DB) → AGiftBatch.BatchDescription (modern in C#); render_job.render_job_id (new, modern in both layers). |
| Numeric precision | Preserve exact NUMERIC(p,s) precision for monetary amounts. Never use floating-point types (float / double) for currency. Preserved gift-domain monetary columns inherit Petra's length=24 decimals=10 — NUMERIC(24,10) for amounts and exchange rates, NUMERIC(5,2) for percentage fields. New monetary columns on operational tables choose precision based on the new domain (NUMERIC(15,2) for SEPA / bank-import amounts where a two-decimal scale matches the underlying instrument). |
Petra is a financial system; rounding errors propagate into donor receipts, tax calculations, and reconciliation. Preserved-side precision matches Petra exactly; new-side precision is chosen for the new instrument's natural scale. | a_gift_detail.a_gift_amount_n preserved NUMERIC(24,10); sepa_export_file.total_amount new NUMERIC(15,2). |
| Preserve-schema discipline | For tables that hold the system of record for posted financial transactions, preserve every column, type, constraint, and index byte-faithfully against the canonical source (Petra db/petra.xml via the human-approved schema-decisions handoff). Augment with additive indexes only; never modify existing indexes. Any deliberate deviation from the source must be explicitly recorded in the handoff and surfaced in the generated artefacts (DDL comment + Section 9 callout). |
Posted-batch immutability (BR-GFT-005) is a domain rule about rows; downstream finance reports depend on the existing schema shape; data migration of a financial system of record is an order-of-magnitude larger problem than code migration. Byte-fidelity assertions catch the failure mode where a "PRESERVED unchanged" block silently fabricates columns or widths. | The eight gift-domain tables are preserved; one declared deviation (a_gift_batch.a_batch_status_c X(8)→16) is surfaced in the handoff and in § 10.4 callout. One additive index ix_gift_batch_landing is created concurrent. |
| Audit trail | created_at, updated_at, created_by, updated_by on all NEW operational entities. Auto-populated via EF Core SaveChangesInterceptor + ambient ICurrentUser service. Preserved tables keep their existing audit columns unchanged; the EF Core mapping reads them through. Petra reality: the gift-domain audit pattern is sparse — a_gift_batch carries one audit column (s_modification_date_d); the other seven preserved gift-domain tables carry none. The universal four-column suffix is added to operational tables only, never grafted onto preserved tables. |
Audit is universal in modern enterprise apps; consistent placement on new tables removes a class of "we'll add it later" debt. Inventing audit columns on preserved tables would be a silent DDL change and a fidelity violation. | All six operational tables carry the four modern audit columns; a_gift_batch.s_modification_date_d mapped through to AGiftBatch.ModificationDate via Fluent API, no DDL change; no created_at / updated_at are added to preserved tables. |
| New operational entities | Mark NEW — no legacy equivalent entities or fields explicitly in migration notes; they are not lossy migrations but additive enhancements to support the new infrastructure pattern (queued workers, idempotency tokens). | Stakeholders need to know which fields are migrated 1:1 vs introduced for modern needs. | All six operational tables (render_job, rendered_receipt, sepa_export_file, bank_import_upload, bank_import_line, idempotency_keys) are marked NEW; nothing migrates into them. |
| Reference data | Read-only consumption of legacy reference tables (a_ledger, a_currency, a_account, p_partner) from gift services. Cache hot reference data (motivation codes) in Redis with TTL + change-notification invalidation. |
Ownership stays with the legacy MFinance / MPartner modules; the slice does not duplicate the data. | Motivation-code autocomplete reads a_motivation_detail via Redis-cached lookup; the underlying table is a preserved gift-domain table, not a copy. |
| String field widths | For preserved tables, EF Core HasMaxLength(N) matches the Petra-declared width verbatim, sourced from the schema-decisions handoff. For new tables, choose width based on the actual domain (VARCHAR(64) for short names / user IDs, VARCHAR(512) for blob URIs, TEXT for free-form prose, JSONB for heterogeneous structured payloads). Cross-table peer columns may legitimately differ — e.g., a_gift.a_reference_c is X(10) but a_recurring_gift.a_reference_c is X(8); both widths are preserved exactly per Petra. |
Length parity with legacy avoids data-truncation bugs during coexistence; new fields don't need to inherit legacy widths; cross-table peer-column differences are real Petra constraints, not artefacts to smooth over. | a_batch_description_c VARCHAR(40) → BatchDescription with HasMaxLength(40); a_currency_code_c VARCHAR(8) → HasMaxLength(8); render_job.failure_reason TEXT (new, unbounded). |
10.9.2 Engine-Specific Rules — PostgreSQL (preserved)
Petra preserves PostgreSQL as the engine. The PostgreSQL column applies; the DynamoDB and Cosmos / MongoDB columns are reference-only, included so this Section 9 contract is portable to a hypothetical future engagement on a different engine without re-deriving the universal rules.
| Decision | PostgreSQL / Azure DB (active) | DynamoDB (reference) | MongoDB / Cosmos DB (reference) |
|---|---|---|---|
| Primary keys | UUID PRIMARY KEY DEFAULT gen_random_uuid() for new operational tables; legacy composite keys ((a_ledger_number_i, a_batch_number_i, ...)) preserved on gift-domain tables; idempotency_keys uses the natural VARCHAR(128) token as PK |
Composite (PK=ledger_number, SK=render_job_id) for operational tables; gift-domain rows would denormalize into single-table design (would require schema migration — not done here) |
_id: ObjectId default; logical key as separate field; gift-domain composite keys would map to a compound natural key field |
| Numeric precision | NUMERIC(24,10) for preserved monetary amounts and exchange rates (Petra); NUMERIC(15,2) for new monetary amounts on operational tables; NUMERIC(5,2) for percentage columns (a_tax_deductible_pct_n) |
N type; document precision in schema-validation Lambda |
Decimal128 BSON type; never Double for currency |
| Relationships | FOREIGN KEY ... REFERENCES ... ON DELETE RESTRICT within gift-domain (preserved); ON DELETE CASCADE for bank_import_line → bank_import_upload; composite-FK fragment for bank_import_line → a_gift_detail (four nullable columns); logical-only FK from rendered_receipt.donor_partner_key → p_partner across the strangler-fig boundary |
Denormalize into single-table design with composite PK/SK; use GSIs for inverse-lookup ("all jobs for ledger X") | Embed bank_import_line rows in bank_import_upload document if line-count bounded; reference matched gift-detail by composite key field |
| Computed values (immutable) | GENERATED ALWAYS AS ... STORED for stable expressions only (e.g., a derived ledger_year_key as ledger_number || '/' || EXTRACT(YEAR FROM gl_effective_date)); never for CURRENT_DATE / CURRENT_TIMESTAMP-dependent values — use views or application properties for those (e.g., RenderJob.IsStale(asOf) is an application property, not a stored column) |
Compute on write at the writing client; or use DynamoDB Streams + Lambda for derived items | Aggregation pipeline expressions for query-time computation; pre-computed fields for hot-path reads |
| Audit trail implementation | 4-column suffix on operational tables + EF Core SaveChangesInterceptor populates them from ICurrentUser; preserved s_modification_date_d on a_gift_batch mapped through unchanged; no audit columns invented on the seven other preserved tables |
Item attributes + DynamoDB Streams → audit log table | Embedded fields + Change Streams → audit collection |
| Schema artefact | EF Core migrations (20260501_AddRenderJob.cs) + auto-generated SQL for operational tables only; checked into Git. Preserved tables configured Fluent-API only with no migration generation. Source-of-truth for preserved-table shapes is the human-approved schema-decisions handoff. |
table-definitions.json + item-schemas/ per item type |
validation-schemas.json per collection |
| Parent-child (e.g., upload → lines) | Child table (bank_import_line) with FK to parent (bank_import_upload) and ON DELETE CASCADE |
Child items (same PK as parent, different SK) | Embedded array (if bounded line count) or separate collection (if unbounded) |
| String widths | VARCHAR(N) matching Petra-declared width on preserved tables (sourced from handoff); VARCHAR(N) with new domain-driven N on new tables; TEXT for unbounded prose; JSONB for heterogeneous structured |
S type with documented constraint in schema validator |
String with maxLength in JSON Schema validator |
| Heterogeneous structured payload | JSONB column with optional GIN index for path queries (used for render_job.job_payload: per-job-type parameters that genuinely differ between AnnualReceipt / SEPA / ICH variants; and idempotency_keys.response_body) |
Native attribute types (M map / L list); document expected shapes in item schema |
Native embedded sub-document; validate via JSON Schema |
For the overall migration execution plan including phased cutover, the GL-write reliability story, and rollback procedures, see Section 11: Migration Strategy.
11. Migration Execution & Legacy/Modern Coexistence
Sections 4 and 9 define what the target architecture looks like and how the gift-domain schema is preserved end-to-end. This section defines how the transition happens — the operational mechanics of running OpenPetra's legacy .asmx server alongside the new ASP.NET Core 8 / Angular 18 services, progressively shifting traffic, and executing a low-risk cutover for the Finance — Gift Processing slice.
The section is organised in six layers: first, why classical strangler fig fits this system (and why SAROC and dual-write do not); second, the queued-worker EIP component view that anchors the receipting subsystem; third, the routing-rules-first cutover choreography; fourth, a worked example showing why ordering and idempotency matter for sequential gift batch numbering; fifth, the proof-of-concept plan (deferred to Phase A of execution); and sixth, operational considerations — DLQ behaviour, queue-depth alerts, consumer-lag SLOs, and rollback.
11.1 Why Classical Strangler Fig Fits Petra Gift Processing
Sage’s coexistence pattern library applies a three-question decision tree to pick the coexistence pattern:
Does the source have a request-routing surface (LB, API gateway, reverse proxy)?
├── No → SAROC
└── Yes → Are writes idempotent and is divergence acceptable for short windows?
├── No → Classical strangler fig (route reads first, then writes)
└── Yes → Symmetric dual-write
Question 1 — routing surface? Yes. Petra's Finance — Gift Processing slice presents an HTTP boundary on every interaction: the JavaScript / jQuery thin client calls server-side WebMethods on ASP.NET .asmx SOAP endpoints over HTTP. The Mono FastCGI process serves these endpoints behind a reverse proxy. Every legacy interaction crosses an HTTP boundary — there is no direct database thick-client surface, no green-screen TUI, no batch-only file drop. This is the exact opposite of ACAS: where ACAS users sit at ACCEPT / DISPLAY COBOL screens with no routable surface, Petra users sit in a browser making JSON-over-SOAP RPCs. SAROC is therefore inapplicable — we have a routing surface, so passive CDC observation is not the right primitive.
Question 2 — writes idempotent? No. The load-bearing write in this slice is gift batch posting. Per BR-GFT-001 (Sequential Gift Batch Numbering), every batch creation runs NewRow.BatchNumber = ++ALedgerTbl[0].LastGiftBatchNumber — an atomic increment of the ledger's batch counter inside a serialisable transaction. Posting the batch then writes journal rows to a_transaction / a_journal with auto-generated journal numbers, and updates motivation YTD / LTD running totals. None of these writes are naturally idempotent. Replaying a posted-batch event without an idempotency token would: (a) consume another batch number from the ledger counter (gap in the sequential invariant), (b) double-post journal rows (corrupting the GL), and (c) double-add to motivation YTD totals (inflating treasurer dashboards and Gift Aid claim totals). Symmetric dual-write is therefore inapplicable — we do not have the idempotency property the pattern requires.
Decision: Classical strangler fig — route reads first, then writes. This is the only branch of the decision tree consistent with both characteristics of the system. Reads (GET /api/Finance/Gift/…) are naturally idempotent and can be forked at the gateway with parity verification. Writes (POST /Finance/Gift/PostBatch) get the gateway-issued idempotency-token treatment so a single client retry cannot double-post.
11.2 EIP Component View — The Receipting Queued Worker
Classical strangler fig itself is a routing pattern (Content-Based Router at the gateway) and does not have a Hohpe & Woolf EIP message-queue diagram of record. However, the modern target architecture does include a queued-worker subsystem inside the gift slice: gift-processing-service submits annual-receipt render jobs to an Azure Service Bus queue (receipt-batch), and gift-receipting-service consumes them, renders PDFs to Blob Storage, and updates the rendered_receipt provenance table.
Figure 11.1 renders that subsystem in the canonical EIP notation. The "legacy" side is the legacy .asmx annual-receipt endpoint that today renders synchronously on the request thread; the "modern" side is the new two-service shape with Service Bus between them. The Channel Adapter here is not CDC-style passive observation (we are not on the SAROC branch); it is the gift-processing-service's outbound queue-publishing client, which serialises the render request and hands it to the durable channel.
11.3 Cutover Choreography
The cutover is a four-phase sequence of routing-rule changes at Azure API Management. The legacy .asmx endpoints stay live throughout Phases A, B, and C; only Phase D decommissions them. Routing changes are policy-file edits, deployable as code, reversible in seconds. This is the key affordance of classical strangler fig — the cutover is configuration, not a database migration.
Sizing context: the Finance — Gift Processing slice is approximately 12,000 LOC of server-side .asmx code (per the candidate-selection handoff — Gift.Adjustment.cs 742, Gift.Batch.cs 200, Gift.Exporting.SEPA.cs 369, Gift.Exporting.cs 870, Gift.Importing.cs 1,998, plus matching JS / SQL) plus the matching jQuery client. A small team (2–3 engineers + 1 QA + part-time PO) should plan 6–8 weeks per phase, with Phase A possibly running longer if parity-diff investigations surface unexpected legacy quirks.
| Phase | Routing rule change at APIM | Verification gate | Rollback procedure | Estimated duration |
|---|---|---|---|---|
| A — Shadow read | Deploy gift-processing-service in shadow mode. APIM routes 5% of GET /api/Finance/Gift/* traffic to the modern service. Responses are compared, not returned — the legacy response goes to the user; the modern response is logged for parity diff. |
Parity-diff rate < 0.1% sustained over a 2-week window (excluding documented schema-drift fields). Latency p95 of modern path within 1.5× legacy baseline. | Set shadow split to 0% in APIM policy. Single-line policy edit + redeploy (~30 seconds). | 6–8 weeks (4 weeks of stand-up + 2–4 weeks of parity-diff investigation) |
| B — Read cutover | APIM flips reads to 100% modern. Legacy .asmx still serves all writes (POST /Finance/Gift/PostBatch, recurring-batch submission, donor-extract creation). Read-heavy screens (gift-batch listing, motivation picker, donor-history scroll) are now backed by the modern stack. |
No 5xx-rate increase vs Phase-A baseline. Treasurer-dashboard load time within budget. PO sign-off after a 2-week observation window. | APIM policy revert — reads route 100% to legacy. Modern stack stays deployed (no destructive change). ~30 seconds. | 6–8 weeks |
| C — Write cutover | APIM routes write endpoints (POST /Finance/Gift/PostBatch, recurring-batch operations, gift-detail edits, annual-receipt-job submission) to modern. Idempotency tokens issued by APIM on every write; gift-processing-service checks the idempotency_keys table before applying the write. Legacy enters read-only mode for any in-flight pre-cutover batches. |
Sequential-batch-numbering invariant holds (no gaps, no duplicates) across the cutover window — verified by a continuous monitor on a_ledger.LastGiftBatchNumber vs MAX(a_gift_batch.batch_number). Motivation YTD totals reconcile against an audit replay of the day's gift events. PO sign-off. |
APIM policy revert restores write traffic to legacy. The idempotency_keys table is cleared after rollback to avoid stale-key collisions if the cutover is reattempted with re-issued tokens. |
6–8 weeks (this is the highest-risk phase — expect investigation cycles) |
| D — Decommission | Legacy .asmx endpoints for gift processing removed from APIM route table. Mono FastCGI process for the gift module shut down. Legacy jQuery gift screens redirected to Angular routes. petra.xml generator no longer runs for gift tables (per target-arch decision). |
No legacy traffic for 14 consecutive days at the APIM telemetry level. PO + change-board sign-off. | Re-add legacy routes from version-controlled APIM policy archive. Restart Mono FastCGI process. Realistically only available for 30–60 days post-decommission before the legacy artifacts are deleted from CI. | 2–4 weeks |
Total elapsed wall time: 20–28 weeks (5–7 months). The Finance — Gift Processing slice is the second-largest module in OpenPetra; smaller modules following the same playbook should compress proportionally.
Federation traffic during coexistence. Both during and after Phase C, the modern gift-processing-service calls back to legacy MFinance via federated REST for two reasons: (a) read-only access to a_ledger, a_account, a_cost_centre, a_accounting_period; and (b) GL posting writes to a_transaction / a_journal. The GL-write boundary is the one place where the new service writes into the legacy zone; per the target-architecture handoff caveat, this is a saga-pattern candidate but the saga boundary is owned by service-architecture (Appendix B), not by this section.
11.4 Why Ordering and Idempotency Matter for Gift Posting
The load-bearing rule for this discussion is BR-GFT-001 — Sequential Gift Batch Numbering (confidence 0.95). The legacy code is one line at csharp/ICT/Petra/Server/lib/MFinance/Gift/Gift.Batch.cs:111:
NewRow.BatchNumber = ++ALedgerTbl[0].LastGiftBatchNumber;
It runs inside a serialisable transaction. Concurrent batch creations on the same ledger serialise via the row lock on a_ledger. The result is a strictly monotonic batch counter per ledger — no gaps, no duplicates — which downstream auditors, treasurers, and tax authorities rely on.
Worked example. Suppose treasurer Sarah submits a gift batch at 14:32 on a Monday in ledger 43 (the UK ledger). The batch contains a single £500 gift from donor partner key 10001234, allocated 60/40 across two motivation details:
- Motivation
MED-EQUIPT— £300 (general medical-equipment fund) - Motivation
BEDS-2026— £200 (specific 2026 hospital-beds appeal)
The batch is created with BatchNumber = 5,847 (the next value after the ledger counter's prior 5,846). When Sarah hits "Post", three things happen atomically:
- Counter increment:
a_ledger.LastGiftBatchNumbergoes 5,846 → 5,847. - Journal write: two rows in
a_journal— debit cash £500, creditMED-EQUIPT£300, creditBEDS-2026£200 (aggregated from the gift-detail rows per the legacy posting rules). - Motivation totals update:
MED-EQUIPT.YTD+= 300 (now £14,300 say);BEDS-2026.YTD+= 200 (now £47,200); the LTD running totals on each motivation also tick up by the same amounts.
What breaks if a write replays without idempotency. The Phase-C cutover sits behind APIM. Suppose Sarah's browser hits a transient network blip, retries the POST /Finance/Gift/PostBatch, and APIM forwards both requests to gift-processing-service — the second one arriving 800ms after the first.
| Effect | Without idempotency token | With idempotency token (this design) |
|---|---|---|
| Batch counter | Increments to 5,848 — a phantom batch consumes a number that no real batch occupies. The sequential invariant breaks. | Stays at 5,847. Second request hits the idempotency_keys table, gets the cached 200 OK response from the first, returns it. No second batch is allocated. |
| Journal rows | Doubled. The GL now reflects £1,000 of cash received against £1,000 of motivation credits — trial balance still balances (because both sides doubled), but the cash actually banked is £500. Reconciliation fails. | Single set of rows. |
| Motivation YTD totals | MED-EQUIPT.YTD shows £14,600 (overstated by £300); BEDS-2026.YTD shows £47,400 (overstated by £200). Treasurer dashboards report inflated raised-to-date. |
Correct. |
| Gift Aid claim | If the gift is Gift-Aid-eligible, HMRC claim file would over-claim 25% (£125) on a phantom donation. This is a regulatory exposure. | Single accurate claim row. |
| Donor receipt (per BR-GFT-004) | If the second batch is also "post-receipt-eligible", the donor receives a duplicate annual-receipt line, with two £500 gifts claimed in their tax return. | Single receipt line. |
The mechanism in this design. APIM is configured to issue a UUID X-Idempotency-Key header on every POST /Finance/Gift/PostBatch that it forwards to gift-processing-service. The service's first action on every write request is to SELECT from a small idempotency_keys table (key, response body, response status, created-at). On hit, it returns the cached response without touching the gift-domain tables. On miss, it begins the EF Core serialisable transaction; only after the transaction commits is the row written to idempotency_keys. A nightly job purges keys older than 24 hours.
Why we picked idempotency tokens over natural keys. Operator-keyed gift batches do not have a stable composite natural key — the legacy system generates the batch number on submission, and the operator-supplied fields (description, effective date, currency) are not collectively unique. This rules out the natural-key approach used in some patterns (e.g., bank reference + amount + value-date for a payment instruction). Idempotency tokens are the safer choice for write-with-side-effects.
11.5 Proof-of-Concept Plan
No PoC has been built yet. Per the agent definition's "no fabricated PoC numbers" rule, this section identifies what the PoC will validate and where in the cutover it lives.
The PoC is Phase A of the execution plan — shadow-mode reads at a 5% gateway split. The PoC has its own validation criteria, distinct from the production-cutover go/no-go gates:
| Validation question | Acceptance criterion | Measurement source |
|---|---|---|
| Does the modern read path return functionally equivalent data? | Parity-diff rate < 0.1% over a 2-week shadow window, excluding documented schema-drift fields (e.g., new server-rendered tax_period_band field). |
Application Insights custom metric parity_mismatch_count / request_count. |
| Is the modern read path fast enough? | Latency p95 within 1.5× legacy baseline. Latency p99 within 2× legacy baseline. | Application Insights duration histogram, segmented by route. |
| Is the receipt-render queued worker stable under realistic load? | Render an annual-receipt batch of 10,000 receipts within a 4-hour wall-time budget; zero messages reaching DLQ in the absence of injected faults. | Service Bus queue metrics + rendered_receipt table count. |
| Does the idempotency-key table behave correctly under retry storms? | Forced retry-storm test (3×-replay every POST for one hour) produces zero double-write side-effects on a_gift_batch / a_journal / motivation YTD totals. |
Custom integration test harness; verification is the BR-GFT-001 sequential-numbering monitor described in 11.3. |
Quantitative numbers will be filled in after Phase A runs. This section will be re-issued via the standard refinement protocol (Section 11 refinement is in scope; the section's structure stays; the PoC subsection swaps "to be validated" for measured numbers).
11.6 Operational Considerations
Once cutover is underway, four operational concerns dominate: dead-letter-queue handling, queue-depth alerting, consumer-lag SLOs, and rollback. The numbers below are calibrated to the Finance — Gift Processing workload shape: low-and-steady interactive traffic plus an annual-receipt surge that can produce 10,000+ render messages in a single hour when the year-end button is pressed.
Dead-letter queue (DLQ) behaviour
Azure Service Bus is configured for 5 delivery attempts per message (the platform default for the Standard tier) before automatic dead-lettering. Reasons for DLQ:
- Poison message: the receipt template references a partner key that no longer resolves (donor merged or deleted). The renderer raises
UnknownPartnerExceptionon every retry. - Transient infrastructure failure exhaustion: Blob Storage 5xx for > 5 retry windows.
- Bug in renderer: e.g., a tax-deductibility-percentage edge case that crashes the PDF generator.
DLQ messages are surfaced via a gift-receipting-service admin endpoint (GET /admin/dlq/receipt-batch) and a corresponding Angular admin screen. Operators can inspect, requeue (after fixing root cause), or discard. DLQ is not auto-drained — a human must classify each message.
Queue-depth alerts
The receipt-render workload is bursty. Queue-depth alerting is calibrated to the year-end surge:
- Warn at 1,000 messages. Normal interactive backlog should never exceed a few dozen messages; 1,000 means a batch run is in flight or something is wrong.
- Page at 5,000 messages. Year-end annual-receipt batches are intentional and well-known; the on-call engineer is paged so they can confirm "annual run in progress, expected" or escalate if not.
- Hard ceiling: 50,000 messages. Service Bus Standard supports far more, but Petra's typical worst case is 10,000 + safety margin. Crossing 50,000 indicates a runaway producer or stuck consumer.
Consumer-lag SLO
For receipt rendering: p95 receipt rendered within 4 hours of POST under normal load (the year-end-surge envelope). Degraded-mode SLO: 24 hours — i.e., within the same business day the operator initiated the run. SLO violation triggers PagerDuty.
For SEPA exports and ICH exports (the other two queues, defined in target architecture but out of this section's narrative scope): minutes-to-hours SLOs apply, calibrated separately during their respective slices' execution. They share the same DLQ + alerting framework.
Cutover rollback
Rollback for any phase is a single APIM policy revert. The policy artifact is version-controlled; revert is a Git revert + redeploy, ~30 seconds at the gateway. There are two rollback nuances:
- Idempotency-key cleanup after Phase C rollback. If write traffic was routed to modern for any non-trivial window before rollback, the
idempotency_keystable holds keys that the legacy system has no awareness of. After rollback, those keys must be cleared (a single DELETE) before reattempt — otherwise re-issued tokens may collide with stale entries and the second cutover attempt would silently no-op some writes. This is documented in the rollback runbook. - In-flight render jobs survive rollback. Service Bus messages enqueued during Phase C are durable; if Phase C is rolled back, the messages already in
receipt-batchcontinue to render — the receipts produced are valid for the gifts that did commit before rollback. A reconciliation run is needed to confirmrender_jobrows align witha_gift_batchrows that committed pre-rollback.
Both nuances are call-outs to the operations team and do not change the cutover plan.
The narrative above is the authoritative specification of the coexistence design. Bridge code generation, routing-rule deployment, and parity verification harnesses are produced by Sage's migration-artifacts workflow against this specification.
12. How Sage Enabled This Analysis
This closing section steps back from the technical content of Sections 1–11 and explains how the analysis itself was produced. Three approaches are compared throughout: (1) With Sage, where the planning analysis has deep insight into all subsystems via Sage's Language-Agnostic Deep Scan, (2) Without Sage (generic AI tooling) — AI assistants analyzing code with only Read / Grep / Glob and no pre-computed codebase intelligence, and (3) Manual review by an architect or migration consultant. Every comparison in this section includes all three, so the value of Sage is visible relative to both AI-without-Sage and traditional human effort.
Section 12 sits at the end of the report on purpose. The earlier sections argued the migration on its merits — the target architecture in Section 4, the platform unlocks in Section 5, the technical-debt classification in Section 6, the UI and code examples in Sections 7–8, the 16 behavioral rules in Section 9, the data-mapping detail in Section 9, and the 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 of analysis 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 27 business-function subsystems and 32 technology subjects — a layered, n-tier .NET Framework 4.7 application with a jQuery + Bootstrap web client, ASP.NET Web Services (.asmx) over Mono FastCGI, an XML-driven code-generated DAL, and a multi-database abstraction over PostgreSQL, MySQL, and SQLite. Sage's Language-Agnostic Deep Scan transformed this heterogeneous C# / JavaScript / SQL surface into structured, queryable intelligence — enabling the planning workflow to evaluate all 27 subsystems, select Finance — Gift Processing as the migration pilot (a five-screen end-to-end donor money-flow slice), design a two-service Azure App Service target, audit 37 dependencies across two contributing manifests, classify 7 architectural concerns + 12 forward-unlock platform constraints against the target, and extract 16 behavioral rules at 0.818 mean confidence — all from pre-computed analysis rather than file-by-file reading.
| Dimension | With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|---|
| Time to Insight | Minutes to hours — structured queries return pre-analyzed intelligence instantly. Candidate selection scored all 27 subsystems in roughly 90 seconds of Sage I/O; behavioral-rule extraction across the gift slice consumed ~33 Sage calls in roughly 4 minutes; the entire report-generation pipeline ran on the order of hours, dominated by LLM composition rather than I/O. | Days to weeks — must read C# files, follow TWebConnector-style RPC method dispatch, build a mental model from scratch. Multi-layer code generation makes naive file reads misleading (generated *.Generated.cs outweighs hand-written code; XML report templates inflate "files" without representing translation work). |
Weeks to months — module walkthroughs, OpenPetra contributor interviews, NAnt build inspection, sample-payload tracing through Mono FastCGI, multi-currency rule walk-throughs with the finance team. |
| Codebase Coverage | 100% (all 572,757 cataloged lines analyzed; full architecture tree across 400,437 lines) | ~5–15% (sampling constrained by context window; ~30–90 files out of the C# / JavaScript / XML surface) | ~1–5% (selective review of MFinance/Gift/, MPartner/, MFinance/validation/, plus a sampling of js-client/src/forms/Finance/Gift/) |
| Subsystems Evaluated | All 27 — every business function scored on Risk / Feasibility / Strategic Value with quantitative inputs (file counts, member counts, cross-subject affinity scores, capability lists). The interactive-UI-surface weighting that drove the selection of Finance — Gift Processing was applied uniformly across all 27, not just the candidates of interest. | 3–5 — limited by time to read each subsystem's webconnector classes, typed-dataset XML, and front-end JS controllers | 1–2 — typically the candidate the customer suggests (Donations Processing) plus one contrast (Finance — Accounting) |
| Confidence Scoring | Quantitative (0.0–1.0) per entity. Behavioral-rule extraction: 16 rules at 0.818 mean confidence (range 0.70–0.95), 13 of 16 at ≥ 0.75. Architectural insight: 0.85 LLM confidence, cycle-stable across re-runs. Headline rules BR-GFT-001 sequential numbering at 0.95, BR-GFT-004 tax-deductible receipt eligibility at 0.87, BR-GFT-005 posted-batch immutability at 0.86. |
None — assertions about which webconnector matters most, or which typed dataset is load-bearing, without quantified support. | Qualitative ("high / medium / low") based on reviewer experience with NAnt-driven .NET Framework projects. |
| Repeatability | Deterministic — the same Sage queries produce the same results; an auditable query trail. Sage results are stable across multiple analysis passes — for example, the architectural-insight returns identical 0.85 confidence on re-run, allowing tech-debt analysis (Section 6) to reuse the cached architectural catalog without re-fetching. | Variable — depends on which .cs / .js / .xml files are sampled and in what order. The dual-storage abstraction (multi-DB factory) is invisible unless multiple connection-factory files happen to be read together. |
Low — different reviewers reach different conclusions, especially about coexistence strategy (strangler-fig vs lift-and-shift) and about which slice to migrate first. |
| Integration Discovery | 151 integration points mapped across the full codebase — .asmx RPC dispatch, Web Connector method invocation, multi-DB factory calls, and PostgreSQL / MySQL / SQLite connections all enumerated. Wire format identified as JSON-over-HTTP RPC despite SOAP envelope — preventing the "SOAP-to-REST" misframing trap. The two-service architectural seam in Section 4 (gift-processing-service ↔ Service Bus ↔ gift-receipting-service) was validated against this integration data — user-flow evidence in Section 7 (Use Case 1 and Use Case 2 cross the seam in opposite directions) confirms the split is workload-shape-driven, not stylistic. |
Explicit method calls in sampled files only. Misses implicit integration through XML-driven code generation (petra.xml → multiple generated DAL files), TDBTransaction delegate scope, and the multi-database-engine abstraction layer. |
Stakeholder interviews plus selective code review — depends on contributor knowledge of how THttpConnector.CallWebConnector dispatches across modules. Often incomplete because Petra's RPC routing is not declarative; it's discovered via reflection of method signatures. |
| Behavioral Rules | 16 distinct rules extracted for the Finance — Gift Processing slice, deduplicated from 24 raw entities (8 same-rule overlaps merged across sibling subjects). 13 of 16 at high confidence (≥ 0.75); 0 below the 0.50 floor. Two headline rules (BR-GFT-004 tax-deductible receipt dual-flag eligibility, BR-GFT-005 posted-batch immutability) are flagged as golden-master-test required. Per-rule citations to file:line-anchor refs verified against actual source. Mean confidence 0.818. |
Could read individual Gift.*.cs files and identify some rules in code it samples, but cannot produce a complete deduplicated catalog with confidence scoring. The cross-subject overlap pattern (the same gift rules surface under both Finance — Gift Processing and Donations Processing in Sage's clustering) 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 migration begins, with confidence scoring and golden-master-test flagging, is rarely applied; rules are re-discovered during the migration when something breaks. |
| Dependency Lifecycle Audit | 37 dependencies catalogued across two manifests with lifecycle status (5 current / 19 outdated / 5 eol-major-version / 7 eol-project / 1 known-cve) and target action (13 removed / 13 replaced / 4 upgraded / 7 outside-slice). The .NET Framework 4.7 NuGet ceiling cascade was discovered through inline packages.config comment evidence: an explicit pin rationale that says, verbatim, "we cannot update to 5.x because that only supports .net 5." |
Could read packages.config and package.json manually, but lacks lifecycle context (which versions are EOL, which have CVEs, which polyfills will be absorbed by .NET 8). Manual cross-referencing against NuGet / npm registries is per-package work. |
Dependency drift typically discovered when a CVE alert lands or a CI build breaks. Strategic patterns (the BCL-polyfill cascade caused by the runtime ceiling) are rarely articulated as a single insight. |
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
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
Complete layered-architecture tree with line counts per layer (Application Layer covering Finance Management at 61.6K lines, Partner Management at 32.6K, Personnel Management at 8.2K; Presentation Layer covering Web Client at jQuery + Bootstrap and Report Templates at 43.5K of XmlReports). Pre-existing classification — "Layered Architecture" with patterns "Code Generation, Repository Pattern, Service Facade, XML-Driven Configuration, Multi-Tier Client-Server, DDD" — surfaced from a single insight query. 34 bounded contexts and 48 aggregate roots enumerated with confidence scores. Sage's bounded-context analysis proposed a single donation-management-service for the gift slice; the target-architecture analysis (Section 4) split it into two on workload-shape grounds (sub-second interactive vs queued worker), and Section 7's user flows confirmed the split — an architectural-validation chain that depends on having the bounded-context evidence in the first place. |
Can determine architecture of individual files by reading them, but cannot classify all 572K lines. Would need to 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 — all in different directories. |
Creates architecture diagrams from contributor knowledge and existing wiki pages. Often reflects the intended N-tier shape rather than the actual shape, where 14% of the codebase is XML report templates that don't fit cleanly into any tier. May not discover that 43.5K lines of report templates inflate the "Presentation" tier without representing interactive UI surface. |
12.2.2 Subsystem Classification
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
All 27 business functions identified and profiled: file count, member count, domain concepts, capabilities, key implementation classes, and cross-subject affinity scores. Finance — Gift Processing profiled with 5 interactive screens, single web-connector entry point (TGiftTransactionWebConnector), and the GiftBatchTDS typed dataset. Cross-subject affinity scores (Finance — Gift Processing ↔ Donations Processing 0.8) materially sharpened the slice-shape decision — the two subjects are the same code surface under different Sage clusters, and the behavioral-rule extraction caught 4 of 5 Donations Processing rules already in the Gift Processing return. |
Could identify subsystems from csharp/ICT/Petra/Server/lib/M* directory names, but quantifying each subsystem's complexity (file count, domain capabilities, integration boundaries) requires reading every webconnector class. With 27 subsystems across hundreds of .cs files, comprehensive profiling is impractical. Likely identifies 5–8 major subsystems (MFinance, MPartner, MPersonnel, MConference, MSponsorship, MReporting, MSysMan) and stops there. |
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. |
12.2.3 Integration Analysis
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
151 integration points mapped across the full codebase with protocol classification. Sage's integration insight identified that the .asmx wire format is JSON RPC despite SOAP envelope framing — a critical signal that prevented the target architecture from over-indexing on a "SOAP-to-REST" migration story. The actual contract is RPC method dispatch via THttpConnector.CallWebConnector; SOAP is cosmetic. The integration entries also identified the multi-DB abstraction (PostgreSQL primary, MySQL / SQLite test) and the GNU Gettext PO-file i18n boundary. The same data validated the two-service Service Bus seam between gift-processing-service and gift-receipting-service. |
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 (where GiftBatchTDS mediates between web-connector and database). Would need to read the NAnt build files, the XML schema, the parser (TDataDefinitionParser), and several web connectors together to understand the generation chain. |
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 until someone reads a sample HTTP capture. |
12.2.4 Code Quality Assessment
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
| 7 architectural concerns surfaced with severity ranking and 0.85 LLM confidence: jQuery + ES5 ceiling (high), multi-currency calculation performance risk (medium), XML report template sprawl (medium), Partner Management transaction-coordination complexity (medium), session-bound auth (medium), shared-utility coupling (low), in-repo dev tooling (low). Each concern is classified against the target architecture (Retired / Mitigated / Out of scope). For the Finance — Gift Processing slice, D-02 (Finance Management) and D-03 (Report Templates) sit in "Mitigated by target" because the gift slice covers a meaningful chunk of both architectural elements. 5 architectural strengths catalogued in parallel (single-source-of-truth schema, code-generation pipeline, multi-database abstraction, cross-layer validation, integration test infrastructure). | 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 — unless it happened to read packages.config and the manifest comments together. |
Code-quality assessment typically done through targeted module reviews. Senior contributors may know which subsystems carry the worst debt (the comment-pinned NuGet packages, the jQuery + Bootstrap-modal stack), but rarely quantify the architectural strengths alongside the weaknesses. Section 6's classification of "what the target retires vs mitigates vs leaves alone" is rarely produced manually. |
12.2.5 Migration Complexity Estimation
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
All 27 subsystems scored on Risk / Feasibility / Strategic Value with quantitative inputs sourced from get_subject_profile (file count, member count, domain concept list, cross-subject affinity). Finance — Gift Processing scored +2.85 once the framework added an interactive-UI-surface column and separated compliance-posture risk from live-regulator-integration risk — up materially from a naive file-count-driven feasibility score that punished the 279-file footprint without recognizing that ~45 of those are XML report templates, ~21 are SQL DDL files, and ~30 more are generated typed-dataset code. The actual hand-translated surface is ~23 server-side C# files (~15K LOC, dominated by Gift.Transactions.cs 6,963 LOC, Gift.Importing.cs 1,998 LOC, Gift.Receipting.cs 1,794 LOC, Gift.Validation.cs 1,690 LOC) plus 5 web-client controllers. The obvious-but-wrong picks were quantitatively rejected: Finance — Accounting (370 files, +0.20 — high value but highest risk), Partner — Contacts Management (211 files, +0.25 — central hub with 17 relationships), System Management — Settings (352 files, −0.25 — plumbing with no demo punch). |
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 and recommends one. Cannot produce quantified risk scores because it lacks the entity-level data (cross-subject affinity, capability inventory) needed for scoring. Would also not be able to distinguish hand-translation files from generated / template / SQL files within a subsystem — the file-count headline drives the wrong conclusion. | Estimation based on experience and analogy. Typically evaluates 1–2 candidates — often the one suggested by stakeholders ("Donations is the most visible workflow, let's start there"). The discovery that Finance — Gift Processing covers the same money-flow narrative in a tractable five-screen slice — with sequential numbering, period validation, posted-batch immutability, and tax-deductible-bounds rules already enumerated — requires comparing alternatives that manual reviews rarely surface. |
12.2.6 Behavioral-Rule Catalog Construction
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
16 distinct rules extracted at 0.818 mean confidence, deduplicated from 24 raw entities across two sibling subjects (Finance — Gift Processing returned 7 rules; Donations Processing returned 5 with 4 already in the primary set). Eight targeted name-substring sweeps (gift, tax, receipt, sepa, posted, motivation, batch) raised the candidate pool from 7 to 24 — catching rules whose Sage-classified business function was attached to a sibling subject but whose code lives in the gift-processing slice's working surface. Each rule carries a confidence score, a Given-When-Then formulation derivable from Sage's workflow data, a verified file:line-anchor reference, a category (Validation / Calculation / State Transition / Workflow / Authorization), and a target-mapping disposition. Two rules (BR-GFT-004, BR-GFT-005) flagged as golden-master-test required. |
Can identify some rules by reading Gift.Validation.cs, Gift.Receipting.cs, and Gift.Transactions.cs, but the rules whose code lives in adjacent files (BankImport/ImportFromMT940.cs for the German-banking-code detection rule; Gift.Adjustment.cs for the modified-detail flag idempotence rule) would only be found by accident. Confidence scoring is not a thing without the underlying entity model. Cross-subject overlap (the same rule classified under two business functions) cannot be detected. |
Behavioral rules typically surface piecemeal — in stakeholder interviews ("oh, by the way, batches can't be modified after posting"), in production incidents ("the import accepted a tax percentage of 110%"), or in code review ("this hard-coded list of banking codes — where does it come from?"). The systematic enumeration with confidence scoring and source provenance is rarely produced before migration; it's discovered during migration, when a missed rule causes a regression. |
12.3 Why Sage Language-Agnostic Deep Scan Matters
Could a generic AI assistant 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 the assistant 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.
Consider the most stakeholder-impactful discovery from this analysis: the .NET Framework 4.7 NuGet ceiling cascade. Petra's runtime ceiling is not a single fact about a single file — it manifests across multiple manifests, multiple packages, and multiple architectural layers. The inline comment in csharp/ThirdParty/packages.config documents the ceiling explicitly: "we cannot update to 5.x because that only supports .net 5." That single pin propagates through Npgsql 4.1.10 (the database driver, EOL major version), NUnit 3.13 (the test runner, outdated), and SharpZipLib 1.3 (a compression library, EOL major version). It also forces six BCL polyfill packages (System.Buffers, System.Memory, System.ValueTuple, System.Runtime.CompilerServices.Unsafe, System.Threading.Tasks.Extensions, System.Runtime) to remain in the manifest because the runtime is too old to provide their functionality natively. On the client side, the same generation of dependency drift forces axios 0.21.x (with two known CVEs), Bootstrap 4 (EOL January 2023), browserify (community-deprecated), and popper.js (renamed). That is 14 packages across 2 manifests and 3 architectural layers — all blocked by the same single ceiling.
A tool analyzing one file at a time cannot see this full picture, and worse, it does not know to look for it. If a generic AI assistant reads packages.config and notices Npgsql 4.x, it correctly identifies an outdated database driver. But without Sage's pre-computed lifecycle classification, it cannot connect that pin to the manifest comment, to the BCL-polyfill cluster, or to the client-side build-chain decay. It might recommend upgrading Npgsql while leaving the runtime ceiling untouched — a migration that looks correct in isolation but cannot actually be performed (Npgsql 5.x requires .NET 5+; the upgrade fails before it starts).
The advantage of Sage's Language-Agnostic Deep Scan 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 Sage analysis has already made — for example, opening packages.config at specific line ranges to confirm the manifest-comment pin rationale, after Sage already identified the dependency lifecycle status. 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
Without Sage (generic AI tooling) = Start with hundreds of .cs, .js, and .xml files. Read some. Grep for patterns. Build a mental model. Hope you found what matters. Rediscover from scratch on every project.
With Sage = Start with 27 classified business-function subsystems, 34 bounded contexts, 48 aggregate roots, 265 project-wide business rules (16 specific to the gift slice at 0.818 mean confidence), 151 integration points, and a 7-row architectural-concerns catalog with severity and target-classification — all with confidence scores and source references. Query what you need. Verify against code only when necessary. Instant, comprehensive analysis.
12.4 Specific Examples from This Analysis
12.4.1 Subsystem Selection Across 27 Subsystems
Sage's candidate-selection step evaluated all 27 business-function subsystems and produced a quantified Risk / Feasibility / Strategic Value score for each. (Note: this analysis used single-pass scoring; Sage’s 3-pass consensus configuration is available for production engagements — the Sage data underlying the scoring is identical regardless of pass count.) Sage’s cached get_subject_profile data, weighed under a framework that includes an interactive-UI-surface column and separates compliance-posture risk from live-regulator-integration risk, produced Finance — Gift Processing as the winner without a single additional Sage call.
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
27 subsystems evaluated with quantified scores. Finance — Gift Processing selected at +2.85, ahead of the runner-up Sponsorship — Child Management (+2.40 with demo-optics adjustment). Top 5 ranked with deltas of 0.45 or less, giving the framework headroom to re-pick defensibly when scoring axes are refined. Total Sage queries for selection: 26 (one get_subject_profile call per candidate, run in parallel batches of 4, completing in ~90 seconds); zero re-fetches required when re-scoring with refined framework axes. |
Would evaluate 3–5 subsystems by reading MFinance/Gift/, MPartner/, MSponsorship/ directory contents. Cannot compare all 27 because reading and understanding each subsystem's web connectors, typed datasets, and JS controllers takes hours per subsystem. Likely defaults to picking Donations Processing or Finance — Gift Processing because they are stakeholder-named — but without the cross-subject affinity score (Donations Processing ↔ Finance — Gift Processing 0.8) cannot recognize they are the same code surface under different cluster labels. |
Evaluates 1–2 candidates, often the one pre-selected by the customer. The discovery that Finance — Gift Processing's 279-file headline collapses to a tractable ~23-file hand-translation surface (the rest is XML report templates, SQL DDL, and generated typed-dataset code) — the kind of insight that turns "too big" into "the right size" — requires evaluating files individually, which manual reviews rarely surface for non-pilot candidates. |
12.4.2 The .NET Framework 4.7 NuGet Ceiling Cascade
The 37-row dependency audit produced a counterintuitive insight: the apparent 37 individual decisions collapse into one strategic decision. Move off .NET Framework 4.7 and onto .NET 8, and 14 of the 37 entries dissolve automatically — the BCL polyfills are absorbed by the modern BCL, the comment-pinned packages can finally upgrade, and the client-side rebuild on Angular 18 retires the entire browserify / Bootstrap 4 / axios chain. This insight required reading the manifest comments — not just listing the packages.
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
37 dependencies catalogued with lifecycle and target-action axes. The cascade pattern emerged from cross-axis classification: 14 entries fall into "removed by target" or "replaced by target" classes that share a single root cause (the .NET Framework 4.7 ceiling). The single most evidence-rich line is the packages.config comment "we cannot update to 5.x because that only supports .net 5" — quoted by Sage's content-range query, not paraphrased. Two dependencies (SharpZipLib 1.3.3, PDFsharp 1.50.5147) classify as "Replaced by target" because the gift-receipting-service worker exercises the bank-import ZIP-decompression and annual-receipt PDF-rendering code paths. |
Would read packages.config and package.json and produce a list of dependencies. Would not necessarily read the manifest comments, where the strategic pin rationale lives. The cascade pattern (one decision dissolves 14 entries) is invisible without the cross-axis classification framework. Would not know to update SharpZipLib's classification when the slice expanded from Sponsorship to Gift Processing — cross-slice classification requires holding both architectural and dependency context concurrently. |
Dependency drift typically discussed at the package level, not the cascade level. The strategic story ("break the ceiling, dissolve the cluster") is a mode of analysis that requires either a tooling investment or a senior architect with .NET-Framework-era memory. |
12.4.3 The .asmx Wire-Format Discovery: Avoiding the "SOAP-to-REST" Misframing
Petra's .asmx services look like SOAP — the file extension, the [WebMethod] attributes, the WSDL endpoint. The naive migration framing would be "migrate from SOAP to REST," with all the WSDL-parsing, contract-first, schema-translation work that implies. Sage's integration insight identified the actual wire format: JSON-over-HTTP RPC, dispatched via THttpConnector.CallWebConnector by method name. The SOAP envelope is cosmetic. This single discovery reframed the target-architecture decision from "SOAP-to-REST" to "RPC-to-Minimal-API" — a materially different (and easier) migration path.
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
The .asmx wire format was identified directly from Sage's integration analysis — multiple integration entries documented JSON-over-HTTP RPC dispatch via THttpConnector.CallWebConnector, with SOAP envelope shape but JSON content. The target-architecture phase committed to ASP.NET Core 8 Minimal APIs without WSDL-translation overhead. Section 8 (Code Translation) Example 8.1 renders the actual translation from [WebMethod] CreateAGiftBatch to a Minimal API endpoint, with the verb-name → HTTP-resource-verb shift made concrete. |
Reading a .asmx file alone would suggest SOAP. Verifying the actual wire format requires either capturing a live HTTP request or finding the JS client code that constructs the request and inspecting its content type. Both are plausible but require knowing to ask the question. The default assumption (".asmx is SOAP") is the obvious failure mode. |
Contributors who write web connectors know how to invoke them but may not have articulated the wire-format detail. The misframing is one of the source profile's documented anti-patterns precisely because it is so easy for a manual reviewer to make. |
12.4.4 Behavioral-Rule Extraction Across Sibling Subjects
The behavioral-rules phase extracted 16 distinct rules at 0.818 mean confidence for the Finance — Gift Processing slice. The methodology choice that mattered: get_entities_by_subject(Finance — Gift Processing) alone returned only 7 rules. Eight targeted search_entities name-substring sweeps (gift, tax, receipt, sepa, posted, motivation, batch) raised the candidate pool to 24 raw entities, deduplicated to 16 distinct rules. Sage's auto-merge of rules across sibling subjects with 0.92–0.94 convergence (where the same rule was classified under both Finance — Gift Processing and Donations Processing) was a load-bearing capability — without it, the dedup pass would have produced a double-counted catalog.
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
16 rules at 0.818 mean confidence (range 0.70–0.95). 13 of 16 at ≥ 0.75 high-confidence; 0 retained below the 0.50 floor. Two headline rules flagged as golden-master-test required (BR-GFT-004 tax-deductible receipt eligibility, BR-GFT-005 posted-batch immutability). Four rules flagged for product-owner review where the modernization changes legacy behavior (banking-code list externalised to config; tax-percentage validation switched from silent-clamp to reject-with-400; motivation defaulting only covers 4 of 9 partner unit types; SEPA mandate format codified from convention to typed value object). All file:line-anchor references verified against actual source — one minor line-number drift on PrintAnnualReceipts.js caught and documented (Sage line 25 vs actual line 11; 14-line GPL header offsets the citation). |
Could read Gift.Transactions.cs and Gift.Validation.cs and identify some rules in those files. Would not find rules whose code lives in BankImport/ImportFromMT940.cs, Gift.Adjustment.cs, or the SQL extract templates without explicitly searching for them. Confidence scoring is not available; cross-subject overlap detection is not available; the systematic 16-rule catalog with golden-master flags would require effort proportional to the sum of per-rule discovery + per-rule analysis effort — on the order of one to two rules per hour of focused reading. |
Behavioral rules surface in stakeholder interviews and code review, often piecemeal and incompletely. The discipline of producing a numbered catalog with confidence scoring and golden-master-test flagging before migration begins is rarely applied; rules are re-discovered during migration when a regression surfaces. |
12.4.5 Architectural-Seam Validation via User Flows
The two-service split — gift-processing-service for sub-second interactive operations and gift-receipting-service for queued workers — is not just an architectural intuition. Sage's bounded-context analysis proposed a single donation-management-service microservice; Sage’s target-architecture analysis split it into two on workload-shape grounds (latency budget, scaling driver, failure mode); the use-case-discovery analysis then validated the split by observing that both primary user flows cross the seam in opposite directions. Use Case 1 (Process a Gift Batch End-to-End) flows from the bank-import worker in gift-receipting-service through Service Bus to interactive batch operations in gift-processing-service; Use Case 2 (Generate the Annual Donor Receipt Run) flows from the form submit in gift-processing-service through Service Bus to receipt-render workers in gift-receipting-service. The seam is justified by user-visible workload-shape evidence, not just stylistic preference — and the validation chain depends on having the integration data, the bounded-context data, and the workflow data all available concurrently.
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
The seam decision was made in target-architecture (Section 4) on workload-shape grounds, validated in use-case-discovery (the input to Section 7) by observing the two primary user flows cross the seam in opposite directions, and rendered in Section 7 with worker / queue annotations on each modern panel. The architectural validation chain depends on the bounded-context data (Sage proposed one service; we split into two) plus the workflow data (Sage's RecurringGiftProcessing 0.84 and DonationReceiptGeneration 0.87) plus the integration-boundary data (the Service Bus seam crosses both flows). Each layer of the chain consumes pre-computed Sage intelligence as primary input; the combination is what produces the validation. |
Could read enough of the gift-processing code to recognize that some operations are interactive and others are long-running, but cannot systematically validate that the split is workload-shape-justified across all primary user flows without enumerating the flows. The "split into two services" decision would likely be made on intuition or by analogy to other projects, not by user-flow evidence. | Service-decomposition decisions in manual reviews are often made via whiteboard sessions weighing concerns like "scalability," "team boundaries," and "deployment isolation" — valid factors, but typically without explicit user-flow evidence. The two-service-because-of-workload-shape framing is defensible but rarely backed by enumerated flow data. |
12.4.6 Platform-Constraint Curation: From 44 Surfaces to 12 Forward-Unlock Entries
The platform affinity analysis (Section 5) curated the tech-debt catalog (44 surfaces — 7 architectural concerns + 37 dependency entries) into 12 forward-unlock entries categorized as Capacity / Processing / UI / Data Type Constraints. The curation decision distribution was 9 ELIMINATE / 3 HYBRID / 0 PRESERVE — a stakeholder-readable answer to the question "what does this migration actually unlock?" Two of those entries (5.1.2 SharpZipLib + PDFsharp; 5.2.1 synchronous-pipeline-for-batch-workloads) reflect the fact that the Gift Processing slice covers a meaningful share of the project surface.
| With Sage | Without Sage (generic AI tooling) | Manual Review |
|---|---|---|
| 12 platform constraints curated from a 44-surface debt catalog, each traced to a Section 6 D-XX concern, an outdated-dependency entry, or a Sage architectural-insight entity. Decision distribution 9 / 3 / 0 presented as a forward-unlock summary. Provenance is auditable: every entry cites its source. The synchronous-pipeline entry (5.2.1) was identified as the strongest single forward-platform-unlock for the gift-processing slice precisely because the queued-worker pattern shows up across both Use Case 1 (bank import) and Use Case 2 (annual receipt run) — the same architectural unlock surfaces twice in the user-flow data. | Could produce a constraint list by reading the codebase, but without the prior architectural-debt classification, the constraints would not be tagged with a target-action decision (eliminated / mitigated / inherited). The forward-unlock framing requires concurrent target-architecture context. The cross-validation with user-flow data (which is what raised 5.2.1 to "headline" status for the gift-processing slice) requires concurrent access to the workflow-entity inventory. | Platform-constraint summaries typically appear in migration RFPs as bullet points without source provenance. The discipline of tracing every constraint to a specific architectural concern, dependency, or Sage entity is rarely applied. |
12.5 The Cumulative Advantage
Each step of this analysis built on the intelligence gathered by earlier steps — a cumulative advantage that compounds with project complexity. Sage's project-intelligence step profiled all 27 subsystems and surfaced 34 bounded contexts; the candidate-selection step used those profiles to score and rank every subsystem (and re-scored without re-fetching when framework axes were refined); the target-architecture step used the selected slice's entity inventory (TGiftTransactionWebConnector, GiftBatchTDS, the five interactive screens, the gift-domain table set) to design the data model and migration shape; the tech-debt analysis step used the architectural-insight catalog plus the dependency audit to produce the severity-ranked debt scorecard; the platform-affinity analysis curated the debt catalog into the forward-unlock constraint summary; the behavioral-rules step extracted 16 rules from sibling subjects with 0.818 mean confidence; the use-case-discovery step validated the architectural seam against user flows; and the technical-examples steps (Sections 7, 8, and 10) consumed every prior layer to render UI mockups, code translations, and data-mapping decisions with quantitative backing rather than narrative assertion.
This chain of dependent analyses was possible because Sage's Language-Agnostic Deep Scan provides a stable, comprehensive, queryable foundation that every step can build upon. Without it, each step would need to start from scratch — re-reading files, re-discovering patterns, re-building context — with no guarantee of consistency between steps. The fact that the architectural-insight result is stable across multiple tech-debt-analysis passes (the same 0.85 confidence, the same 7-concern catalog, the same 37-row dependency audit) is concrete evidence of that foundation: the same query produces the same answer when re-issued, which lets each downstream step trust its inputs without re-validation. Re-scoring all 27 subsystems with a refined framework required zero additional Sage calls because the cached get_subject_profile data was still authoritative.
By the Numbers: This Analysis
| Metric | With Sage | Without Sage (generic AI tooling, estimated) | Manual Review (estimated) |
|---|---|---|---|
| Lines analyzed | 572,757 (100%) | ~30K–90K (~5–15%) | ~5K–30K (~1–5%) |
| Subsystems evaluated | 27 (100%) | 3–5 | 1–2 |
| Bounded contexts identified | 34 (with confidence scores) | 5–10 (from directory inference) | 3–6 (from contributor knowledge) |
| Aggregate roots | 48 (Partner aggregate at 0.91 confidence and 17 relationships) | Not formally enumerated | Not formally enumerated |
| Integration points mapped | 151 (full codebase, with wire-format detail) | 10–20 (sampled files) | 5–10 (known integrations) |
| Dependencies audited | 37 (lifecycle + target-action classified across 2 manifests) | ~15–25 (manifest enumeration only, no lifecycle context) | 0–5 (reactively, when CVEs land) |
| Architectural concerns catalogued | 7 (severity-ranked, target-classified) | 2–4 (in sampled files) | 2–3 (well-known issues) |
| Platform constraints (forward-unlock) | 12 (9 ELIMINATE / 3 HYBRID / 0 PRESERVE) | 3–5 (in sampled files) | 2–4 (well-known limits) |
| Behavioral rules in slice | 16 (0.818 mean confidence; 13 at ≥ 0.75; 2 golden-master-flagged) | 4–8 (rules visible in sampled files; no confidence) | 3–6 (from interviews + code review) |
| Estimated total effort | Hours (automated pipeline) | 2–3 weeks | 4–8 weeks |
12.6 What This Means for Your Migration
The depth and breadth of this analysis — 27 subsystems evaluated, 34 bounded contexts identified, 151 integration points mapped, 37 dependencies audited, 7 architectural concerns classified against the target architecture, 12 forward-unlock platform constraints curated, and 16 behavioral rules extracted at 0.818 mean confidence — would be impractical to produce manually for a 572,757-line .NET / jQuery codebase. Even with general-purpose AI code-analysis tooling, the absence of pre-computed codebase intelligence would reduce coverage from 100% to approximately 5–15%, with corresponding gaps in integration mapping (the SOAP-vs-JSON-RPC wire-format distinction would likely be missed), constraint discovery (the .NET Framework 4.7 ceiling cascade would surface as 14 isolated package decisions rather than one strategic one), behavioral-rule completeness (rules whose code lives in adjacent files would be missed entirely), and architectural-debt classification.
Sage's Language-Agnostic Deep Scan does not replace human judgment — it replaces the weeks of manual code reading required to inform human judgment. Every decision in this report — from selecting Finance — Gift Processing as the pilot slice, to choosing a two-service Azure App Service target with Service Bus between them, to preserving the entire gift-domain schema end-to-end, to enumerating 16 behavioral rules with golden-master-test flags on the two load-bearing ones — is backed by quantified evidence from the full codebase, not sampled approximations.
Ready to see what Sage can discover in your codebase? Visit sage-tech.ai to learn about pilot programs and schedule a Sage Language-Agnostic Deep Scan for your legacy modernization project.
Appendices
Advanced analysis and supporting artifacts
Appendix A: Multi-Agent Subsystem Selection
This appendix documents the subsystem-selection methodology used to pick the migration pilot candidate from among 27 documented business-function subsystems in Petra (OpenPetra). A single evaluation pass was conducted for this analysis; Sage’s three-pass consensus methodology (which would re-score independently three times and reconcile) is documented in A.2 and is available for production engagements.
A.1 Selected Candidate
Recommended Candidate: Finance — Gift Processing
Risk: 5.5 / 10 (moderate) | Feasibility: 6.0 / 10 (good) | Strategic Value: 9.0 / 10 (high) | Weighted Total: +2.85
A.2 Scoring Formula and Methodology
All subsystems were scored on the same weighted 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 +5.10 (Risk=0, Feasibility=10, Strategic Value=10).
Dimension Definitions
| Dimension | Weight | Direction | Sub-Factors |
|---|---|---|---|
| Risk | 40% | Lower is better | Business criticality, integration complexity, data-migration risk, compliance posture (treated separately from live regulator integration), blast radius |
| Feasibility | 30% | Higher is better | Hand-translation surface (file count excluding generated code, template assets, and SQL DDL files), dependency isolation, complexity, team familiarity, tech-debt level |
| Strategic Value | 30% | Higher is better | Business value, cost savings, pattern reusability, skill-building, stakeholder visibility, interactive UI surface (count of distinct interactive web screens), demo-narrative cleanliness |
Methodology Notes Specific to .NET / AngularJS-Era Sources
Two scoring rules were applied per the dotnet-angularjs source-profile anti-pattern guidance:
- Template assets are not UI files. XML report templates, RDL files, and inline HTML report templates are not counted as interactive UI surface. They port through the template engine, not as hand-translated screens. Only form templates paired with web-client controllers count toward the interactive-UI-surface tally.
- Generated code and SQL DDL are not part of the hand-translation surface. Petra's typed-dataset code (
*.Generated.cs) ports through the generation pipeline, not by hand. SQL DDL / migration files port through EF Core migrations. Both are excluded from the file count that drives Feasibility scoring.
Three-Pass Consensus (Methodology Reference Only)
In a full production engagement, this evaluation is run three times independently with different scoring emphases — technology-driven, business-driven, hybrid-risk-mitigation — and the results reconciled. If two of three passes agree on the top candidate, that is the consensus pick. If all three differ, a weighted average across passes is used. For this analysis a single pass was conducted; consensus reconciliation is available in production engagements.
A.3 All-Subsystem Scoring Matrix
The matrix below scores all 27 documented subsystems. The UI column is the count of distinct interactive web-client screens (form templates paired with web-client controllers); template assets are not counted. Files is the total Sage-cataloged file count for the subsystem (which over-counts hand-translation work because it includes generated code, templates, and SQL DDL). Two subsystems (Sponsorship — Donor Management, Sponsorship — Participant Tracking) are listed by Sage but their profile lookups returned no results — logged as a Sage coverage gap and unscored.
Tier 1 — Top-Ranked Candidates
| Rank | Subsystem | Files | UI | Risk | Feasibility | Strategic Value | Weighted |
|---|---|---|---|---|---|---|---|
| 1 | Finance — Gift Processing | 279 | 5 | 5.5 | 6.0 | 9.0 | +2.85 |
| 2 | Sponsorship — Child Management | 12 | 1 | 3.5 | 8.5 | 6.0 | +2.40 |
| 3 | Conference — Transportation | 73 | 0 | 4.0 | 7.5 | 5.0 | +2.30 |
| 4 | Reporting — Custom Reports | 20 | 1 | 4.5 | 7.0 | 6.0 | +2.10 |
| 5 | Hospitality — Accommodation | 5 | 0 | 4.0 | 8.5 | 4.0 | +2.10 |
| 6 | Sponsorship — Program Management | 12 | 0 | 4.5 | 7.5 | 4.5 | +1.95 |
| 7 | Conference — Accommodations | 73 | 0 | 4.5 | 7.0 | 4.5 | +1.65 |
| 8 | Conference — Registration | 70 | 0 | 5.0 | 7.0 | 5.0 | +1.60 |
| 9 | Reporting — Standard Reports | 31 | 0 | 5.0 | 6.5 | 5.5 | +1.60 |
| 10 | Finance — Budgeting | 160 | 2 | 5.5 | 6.0 | 6.0 | +1.55 |
| 11 | Conference — Event Management | 77 | 0 | 5.0 | 6.5 | 5.0 | +1.45 |
Tier 2 — Mid-Ranked Candidates
| Rank | Subsystem | Files | UI | Risk | Feasibility | Strategic Value | Weighted |
|---|---|---|---|---|---|---|---|
| 12 | CRM Functions | 175 | 2 | 5.0 | 5.5 | 5.5 | +1.30 |
| 13 | Personnel — Volunteers | 98 | 1 | 5.5 | 6.0 | 5.5 | +1.25 |
| 14 | Personnel — Staff Management | 98 | 1 | 5.5 | 6.0 | 5.5 | +1.25 |
| 15 | Donations Processing (sparse tag) | 279 | (overlaps Gift Processing) | 6.5 | 4.5 | 7.0 | +1.05 |
| 16 | Reporting — Financial Statements | 201 | 1 | 6.0 | 5.0 | 6.5 | +1.05 |
| 17 | Finance — Banking | 177 | 2 | 6.5 | 5.0 | 6.5 | +0.85 |
| 18 | System Management — Access | 58 | 1 | 7.5 | 6.5 | 6.0 | +0.75 |
| 19 | Partner — Families | 185 | 1 | 6.5 | 5.5 | 5.5 | +0.70 |
| 20 | Partner — Persons | 210 | 2 | 7.0 | 5.0 | 5.5 | +0.35 |
| 21 | Partner — Organizations | 203 | 2 | 7.0 | 5.0 | 5.5 | +0.35 |
| 22 | Partner — Contacts Management | 211 | 3 | 8.0 | 4.5 | 7.0 | +0.25 |
| 23 | Finance — Accounting | 370 | 4-5 | 8.5 | 4.0 | 8.0 | +0.20 |
Tier 3 — Low-Ranked / Not Suitable for Pilot
| Rank | Subsystem | Files | UI | Risk | Feasibility | Strategic Value | Weighted |
|---|---|---|---|---|---|---|---|
| 24 | System Management — Settings | 352 | 1 | 7.0 | 4.5 | 4.0 | −0.25 |
| 25 | System Management — Users | 390 | 2 | 8.0 | 4.0 | 5.0 | −0.50 |
| n/a | Sponsorship — Donor Management | Profile lookup returned no results — Sage coverage gap; unscored. | |||||
| n/a | Sponsorship — Participant Tracking | Profile lookup returned no results — Sage coverage gap; unscored. | |||||
A.4 Why Finance — Gift Processing Wins
Score Breakdown
| Dimension | Score | Reasoning |
|---|---|---|
| Risk | 5.5 / 10 (moderate) | Compliance content exists in the slice (tax-deductible receipt rules, gift-batch immutability after posting, multi-jurisdiction tax classification) but is implemented as data and validation rather than as live integration with tax authorities. SEPA / DTAUS / MT940 are file-based imports/exports, not real-time API calls. Coupling to Partner is read-only (donor identity is consumed, not authored); coupling to GL is one-way (post-batch only). Blast radius for a strangler-fig deployment: contained to the gift workflow. |
| Feasibility | 6.0 / 10 (good) | The 279-file headline overcounts. Subtracting 45 XML report templates, 21 SQL DDL / migration files, and ~30 generated typed-dataset files leaves ~28 hand-translated files: 23 server-side C# files (~15K LOC of real work) + 5 web-client controllers + form templates. Largest server files: Gift.Transactions.cs (6,963 LOC), Gift.Importing.cs (1,998 LOC), Gift.Receipting.cs (1,794 LOC), Gift.Validation.cs (1,690 LOC). Single web connector (TGiftWebConnector) and single typed dataset (GiftBatchTDS) follow the same translation pattern proven on smaller Petra slices. |
| Strategic Value | 9.0 / 10 (high) | Five interactive web screens (Gift Batches, Recurring Gift Batches, Motivations Setup, Bank Import, Print Annual Receipts) — the largest interactive UI surface among any defensible pilot candidate. Demos as an end-to-end donor money-flow narrative (bank import → batch → receipt). Demo-narrative cleanliness: positive (donations + receipts is a benign, business-clean story). Patterns proven here apply directly to Finance — Banking, Finance — Accounting, donor extracts, and any multi-screen workflow in the catalog. |
| Weighted Total | +2.85 | |
vs. Sponsorship — Child Management (rank 2, +2.40)
Sponsorship — Child Management is the smallest defensible slice in Petra (12 files; ~3K LOC of hand-written code; one screen) and is — on pure engineering grounds — an excellent first pilot. Two factors push it below Gift Processing in this evaluation:
- Interactive UI surface = 1. A single interactive screen (
MaintainChildren) limits Section 7's UI/UX transformation analysis to one form's worth of paradigm-shift material. Five screens give the modernization narrative considerably more to work with. - Demo-narrative cleanliness penalty. The slice's dominant interactive surface is a child-photo-upload + sponsored-child-status workflow. For a prospect-facing demo, this is not the right material — the slide turns into a conversation about safeguarding and minor-protection policies rather than about migration patterns. A −2.0 adjustment to Strategic Value (8.0 → 6.0) reflects that.
The combined effect (loss of 2.0 Strategic Value points + Gift Processing's +1.5 lead on the same dimension at the unadjusted level) makes Gift Processing the pick.
vs. Conference — Transportation, Hospitality — Accommodation, and Other Small-Surface Subsystems (ranks 3–9)
Three of the small-surface candidates that scored high in earlier rankings — Conference — Transportation, Hospitality — Accommodation, Conference — Registration — have an interactive UI surface of zero. Petra's historical Windows Forms client (which provided the user-facing surface for these modules) was retired; the remaining surface is server-side REST endpoints and report templates. As pilot candidates these subsystems would teach the team to migrate a server module but would not exercise the UI/UX transformation patterns that dominate the modernization narrative for an AngularJS-era source. Their Strategic Value was re-rated downward to reflect this.
vs. Finance — Accounting, Partner — Contacts Management, System Management — Users (high-risk candidates)
The largest, highest-value subsystems remain unsuitable as pilots:
- Finance — Accounting (370 files, Risk 8.5) is the heart of month-end close with five distinct compliance axes (multi-currency conversion, posting period validation, German GDPdU export, cost-centre hierarchy, year-end close). Should be migrated last, not first.
- Partner — Contacts Management (211 files, Risk 8.0) is the central entity in Petra's domain model (17 relationships, 0.91 confidence). Migrating it first forces every other slice to federate through the new identity store. Doing it later means each slice can read the legacy partner store via the strangler bridge.
- System Management — Users (390 files, Risk 8.0) involves auth flows, password hashing, and session handling — net-negative weighted score and unsuitable for a first migration.
A.5 Sub-Slice Scoping Decision
Finance — Gift Processing decomposes naturally into several sub-slices. The matrix below shows them; the recommendation is to pick the broader slice rather than any single sub-slice.
| Sub-slice | Screens | Hand-written server files | Selected? |
|---|---|---|---|
| Receipt Generation alone | 1 (Print Annual Receipts) | Gift.Receipting.cs (1,794 LOC) + receipt template engine | No — single-screen slice |
| Gift Batch Management alone | 2 (Gift Batches, Recurring Gift Batches) | Gift.Transactions.cs (6,963 LOC), Gift.Validation.cs (1,690 LOC) | No — bigger but still narrow |
| Bank Import alone | 1 (Bank Import) | Gift.Importing.cs (1,998 LOC) | No — single-screen slice |
| Donor Extracts alone | 0 (read-only reports only) | extract / query helpers | No — UI surface = 0 |
| Motivations Setup alone | 1 (Motivations Setup) | configuration helpers | No — admin-only screen |
| Finance — Gift Processing (whole slice) | 5 screens | ~23 server-side C# files (~15K LOC) | Yes — recommended |
Why the broader slice over any single sub-slice:
- Demo material. Five screens give the modernization analysis enough surface to demonstrate AngularJS → Angular paradigm shifts on real workflow forms. A single sub-slice puts the demo back to one or two screens.
- Shared infrastructure. All five screens use
TGiftWebConnectorandGiftBatchTDS. Migrating all five is roughly one connector port + one typed-dataset port + five Angular components — not 5x the work. - End-to-end money-flow narrative. Bank import → batch → receipt is the cleanest version of Petra's flagship donor workflow. Cutting it up forces the demo to handwave around missing pieces.
- Translation surface stays tractable. ~15K LOC of hand-written server logic + 5 web-client controllers is comfortably within strangler-fig pilot scope. Larger than Sponsorship's ~3K but well below Finance — Accounting's ~60K.
- Templates and SQL files are not added work. The 45 XML report templates port through the template engine and the 21 SQL DDL files port through EF Core migrations — both are present in any of the sub-slices anyway.
A.6 Sage MCP Usage
| Tool | Calls | What it gave us |
|---|---|---|
get_project_business_function_subjects | (carry-forward, 1) | 27 documented business-function subjects. |
get_subject_profile (business_function) | (carry-forward, 25) | File counts, member counts, domain concepts, capabilities, cross-subject affinity scores. The single most useful tool for candidate scoring. |
| Cached candidate-selection evidence | 1 read | Previously gathered evidence (file counts, member counts, capability lists) was retained and re-scored under the current framework. No re-fetching was required. |
Total new Sage calls for this scoring pass: 0. The scoring is a re-evaluation of cached evidence with two scoring axes — interactive UI surface and demo-narrative cleanliness — not a re-fetch of the underlying data.
A.7 Caveats
- Single-pass scoring. A single evaluation pass was conducted for this analysis; Sage’s three-pass consensus reconciliation is available in production engagements.
- Numeric scores are LLM-derived heuristics, not measurements. The ranking is robust (Gift Processing's UI-surface lead is real and substantive); exact deltas between near-neighbors are within scoring noise.
- Interactive-UI-surface counts come from a follow-up source-tree analysis rather than from a fresh Sage subject-file query; in a production engagement those counts are re-confirmed against the source tree.
- Two subsystems remain unscored (
Sponsorship — Donor Management,Sponsorship — Participant Tracking) due to a Sage profile-coverage gap. The interactive-UI-surface re-weighting makes Gift Processing's lead robust against any reasonable score for those two subjects, but the gap is logged.
Appendix B: Service Architecture Decision Process
This appendix documents how the Finance — Gift Processing slice was decomposed into modernized services. Section 4.5 stated the result — two services, gift-processing-service and gift-receipting-service — without showing the work. This appendix shows the work: the workload-shape evidence that drove the decision, the alternatives considered, the seam chosen for the boundary, the cross-service communication pattern, and the federated-read posture against the legacy zone.
The headline finding is that the slice contains two genuinely different workload shapes — synchronous interactive request-response on one side, asynchronous queued-worker batch processing on the other — and that collapsing them into a single service trades away the architectural property that most justifies the modernization (independent scaling of receipt rendering against gift entry). The queued-worker boundary is the natural seam, and the posted-batch immutability invariant (BR-GFT-005) is what makes the seam safe.
B.1 Why this slice has TWO services, not one
The Gift Processing surface, viewed as code, looks deceptively uniform — one bounded context (DonationManagement), one schema family (the a_gift_* and a_recurring_gift_* tables), one set of business rules. But viewed as workload, it splits cleanly into two shapes:
| Workload axis | Synchronous interactive shape | Asynchronous batch-mode shape |
|---|---|---|
| Representative operations | Gift batch creation, gift detail entry, batch posting, motivation catalog maintenance, recurring-gift authoring, donor extract reads | Annual receipt PDF rendering, SEPA Direct Debit XML export, ICH cross-border export, bank statement file ingest and matching |
| Latency budget | Sub-second p95 — user is waiting at a screen | Minutes to hours per job — user submits, walks away, returns for results |
| Trigger | HTTP request from the Angular SPA | Job submission landing on a Service Bus queue (`receipt-batch`, `sepa-export`, `bank-import`) |
| Resource profile | Low-and-steady CPU; many small DB reads/writes; connection-pool friendly | Bursty CPU on the annual-receipt run (template render + PDF compose at 10K+ documents/hour); long-held DB transactions; large blob writes |
| Peak pattern | Business hours, surges around year-end fundraising appeals | Concentrated cycles — January 15 annual-receipt run, monthly SEPA collection date, ad-hoc bank-import days |
| Scaling driver | Concurrent users / HTTP request rate | Service Bus queue depth (per-ledger session count for the receipt path) |
| Failure mode | User-visible 5xx; correlation-id-traced; immediate retry | Job lands in the dead-letter queue; admin-surface retry; no user blocked |
| Source-side evidence | TGiftTransactionWebConnector, TGiftBatchFunctions, Gift.Adjustment.cs, Gift.GiftDetailFind.cs, donor-extract SQL templates — all called per-screen-action |
TGiftExporting, TGiftExportingSEPA, the annual-receipt rendering path, TGiftImporting bank-statement parser — all triggered per-job and historically run as long-lived synchronous calls that monopolised threads |
Two shapes in one process means one of two outcomes, both bad: either the receipt-rendering run starves interactive requests during the January annual-receipt cycle (the legacy system's existing pain — a five-minute receipt batch monopolises server threads and the donor-entry screens go slow), or the App Service plan is sized for the burst and sits under-utilised the rest of the year. The two-service split lets each side scale on its own driver, which is the architectural property that makes the queued-worker pattern worth introducing in the first place.
B.2 Service granularity decision matrix
Three options were evaluated against the same four criteria used elsewhere in this report — Operational Complexity (20%), Business Alignment (30%), Technical Soundness (30%), and Change Velocity (20%) — with a 7.0 minimum weighted score for acceptance.
| Option | Shape | Pros | Cons |
|---|---|---|---|
| Option 1 Single service |
One gift-service hosts everything — HTTP endpoints and queued workers in one App Service plan, one process, one deployment unit. |
Simplest operational footprint. One App Service plan, one CI/CD pipeline, one runtime configuration surface. Easiest read-your-own-writes story (no cross-process data hand-off). | Receipt-rendering run monopolises threads and degrades interactive latency. Scaling decisions become compromises: scale up for the burst, waste compute the rest of the year; scale for steady-state, tank the receipt run. Loses the architectural headline of the modernization. |
| Option 2 Two services (CHOSEN) |
Synchronous-interactive gift-processing-service and asynchronous-batch gift-receipting-service, each on its own App Service plan, communicating via Azure Service Bus and a shared Postgres database with explicit table ownership. |
Independent scaling against the right driver per side. The queued-worker pattern becomes a first-class architectural feature, not an afterthought. Failure-mode boundaries are clean (interactive 5xx vs DLQ). Change velocity per side — receipt-template work doesn't redeploy gift entry. | Two App Service plans instead of one. Slightly more deploy ceremony. Cross-service trace correlation requires Service-Bus-aware OpenTelemetry instrumentation (which is in the resolved patterns). |
| Option 3 Three+ services |
One service per workload axis — e.g., gift-entry-service, gift-posting-service, annual-receipt-service, sepa-export-service, ich-export-service, bank-import-service. |
Maximally aligned with workload shape. Each batch path can scale and deploy fully independently. Failure of one batch type cannot affect any other. | Over-decomposition for a single subsystem at current usage volumes. Coordination overhead (service catalog, cross-service contracts, six App Service plans, six CI/CD pipelines) does not pay off until the slice scales 10x. Premature distribution. |
| Criterion (Weight) | Option 1: Single | Option 2: Two (chosen) | Option 3: Three+ |
|---|---|---|---|
| Operational Complexity (20%) | 9.0 — lowest overhead, single deploy unit | 7.5 — two plans, two pipelines, manageable | 5.5 — six services for one subsystem is too many moving parts |
| Business Alignment (30%) | 6.0 — conflates two business rhythms | 8.5 — matches the “daily gift entry vs cyclical batch” rhythm finance staff already live with | 7.0 — aligned, but no business team is large enough to own per-axis service ownership |
| Technical Soundness (30%) | 5.5 — workload-mismatch is the structural flaw the modernization is trying to fix | 9.0 — queued-worker pattern realised, scaling driven by the right signal per side, posted-batch immutability gives the seam safety | 7.5 — technically sound, but distributes problems that don’t need distributing yet |
| Change Velocity (20%) | 5.5 — receipt-template change redeploys gift entry | 8.0 — per-side deploy independence | 8.5 — full per-axis deploy independence |
| Weighted total | 6.40 | 8.30 | 7.05 |
Option 2 wins decisively on Technical Soundness and Business Alignment — the two highest-weight criteria — while paying only a modest Operational Complexity premium relative to Option 1. Option 3 clears the 7.0 floor but does not justify its additional complexity at this slice's scale. The decision is not close.
B.3 Service boundaries — what each service owns
The two services divide responsibility along the synchronous / asynchronous workload seam. Within that division, table ownership is explicit: each net-new operational table has a single writer; the preserved gift-domain tables are written by one service and read by the other.
gift-processing-service
| Attribute | Detail |
|---|---|
| Workload shape | Synchronous HTTP request-response. ASP.NET Core 8 Minimal APIs (with controllers for complex routes). One App Service plan (P1v3), 2–6 instances, autoscale on CPU and HTTP queue length. |
| Endpoint families | /api/gift/batches (CRUD + post + adjust), /api/gift/recurring (recurring-gift authoring with SEPA mandate), /api/gift/motivations (catalog maintenance + auto-resolve), /api/gift/donor-extracts (Dapper-backed dynamic-SQL reads) |
| Writes (gift-domain 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 |
| Owns (operational tables) | idempotency_keys — X-Idempotency-Key persistence for write endpoints (POST batch, POST post, POST adjust) |
| Federated reads (legacy zone) | Donor identity (MPartner: p_partner, p_family, p_person, partner class), ledger / period / account / cost-centre reference data (MFinance), tax-deductibility ceiling lookups |
| Federated writes (legacy zone) | GL posting at batch-post time — rows on a_transaction / a_journal are written via federated REST to legacy MFinance, never directly. This is the only write path from the new services into the legacy zone. |
| Publishes | Service Bus messages: RenderAnnualReceiptJob (per-ledger session), RunSepaExportJob, RunIchExportJob, ProcessBankStatementUploadJob |
| Authentication | Microsoft Entra ID (OIDC) JWT bearer tokens; role claim required for batch-posting endpoints; legacy session token bridge during coexistence window |
gift-receipting-service
| Attribute | Detail |
|---|---|
| Workload shape | Asynchronous queued worker. ASP.NET Core 8 BackgroundService hosting a ServiceBusSessionProcessor (per-ledger session ordering on the receipt path) plus standard ServiceBusProcessor for SEPA / ICH / bank-import. One App Service plan (P1v3), 1–4 instances, autoscale on Service Bus queue depth. |
| Endpoint families | Job-control surface only: /api/gift/receipts (submit / status / result), /api/gift/sepa, /api/gift/ich, /api/gift/bank-import — small, narrow, no business logic in the HTTP layer; the work happens on the queue consumer. |
| Reads (gift-domain tables) | All eight gift-domain tables — read-only. The receipt renderer reads from a_gift_batch / a_gift / a_gift_detail joined to motivation and donor data. Critically, it reads only from posted batches. |
| Owns (operational tables) | render_job (job status state machine), rendered_receipt (per-receipt provenance with SHA-256 hash and immutable-until timestamp), sepa_export_file, bank_import_upload, bank_import_line |
| Owns (blob containers) | receipts/{ledger}/{year}/{donor}/{receipt_id}.pdf (immutability policy), sepa-exports/{ledger}/{date}/..., ich-exports/..., bank-imports/{ledger}/{date}/... |
| Federated reads (legacy zone) | Donor address and partner-class lookups for receipt rendering; fiscal-period validation for SEPA / ICH |
| External outputs | SEPA Direct Debit XML files written to blob; bank delivery (SFTP / portal upload) is unchanged from legacy and out of scope for this slice. ICH cross-border export files written to blob. |
| Authentication | Same Entra ID bearer-token model on the job-control HTTP endpoints; queue-consumer side uses managed-identity to Service Bus. |
Table ownership summary
| Table family | Writer | Reader(s) | Notes |
|---|---|---|---|
Gift-domain (a_gift_batch, a_gift, a_gift_detail, motivations, recurring gifts) |
gift-processing-service | both services | Schema preserved end-to-end. Receipting reads only from batch_status = ‘Posted’. |
idempotency_keys |
gift-processing-service | gift-processing-service | X-Idempotency-Key persistence for retry-safe writes. |
render_job |
gift-receipting-service | both (status reads) | Job state machine: Queued → Running → Completed / Failed. |
rendered_receipt |
gift-receipting-service | gift-receipting-service | Provenance row per PDF; SHA-256 hash; immutable-until timestamp for tax-audit retention. |
sepa_export_file, bank_import_upload, bank_import_line |
gift-receipting-service | gift-receipting-service | Operational provenance and unmatched-line queue. |
Legacy reference (p_partner, a_ledger, a_account, …) |
legacy MPartner / MFinance | both services (federated) | Read via HTTP RPC against legacy .asmx endpoints. |
Legacy GL (a_transaction, a_journal) |
legacy MFinance (called by gift-processing-service) | legacy MFinance | The only write from the new services into the legacy zone — happens at batch-post time via federated REST. |
B.4 The queued-worker boundary as the natural seam
Choosing to split somewhere is not enough — the boundary has to be at a seam where the data doesn't fight back. The queued-worker boundary is that seam, and the reason it is safe is BR-GFT-005 (posted-batch immutability).
BR-GFT-005 says: once a gift batch transitions to batch_status = ‘Posted’, no field on its rows or its children may change. Adjustments are modelled as new gift entries that reference the posted batch, never as edits-in-place. The legacy system has enforced this rule for fifteen-plus years; finance staff and audit procedures rely on it; the resolved patterns table (Section 4) elevates it from an implicit convention into a typed domain rule (BatchAlreadyPostedException).
That invariant is what makes the seam work. Receipting reads only from posted batches. A posted batch cannot change. Therefore the receipting service cannot suffer a read-your-own-writes problem against the gift-processing service: by the time receipting sees a batch, the gift-processing side has already declared the batch closed and the rows immutable. There is no concurrent-modification window to defend against; no read-after-write consistency contract to negotiate; no version-vector reconciliation; no “eventually” in the eventual consistency story for the data receipting actually depends on.
Compare this to alternative boundaries that were implicitly considered and rejected:
| Hypothetical seam | Why it would be worse |
|---|---|
| Split between gift-CRUD and gift-posting | Both sides write to a_gift / a_gift_detail in overlapping time windows. Posting needs to read what entry just wrote; entry needs to learn that its batch was posted. That is a read-your-own-writes problem with no immutability invariant to fall back on. Either you put both in one service (Option 1, which loses the scaling argument) or you introduce eventual consistency for data with strong-consistency expectations (which finance staff will see as a regression). |
| Split between motivations and gifts | Motivation lookup is on the gift-detail-entry hot path. Splitting it out introduces a synchronous cross-service hop into the most latency-sensitive flow in the slice for no operational gain — motivations are tiny reference data and don’t scale or fail differently from gifts. |
| Split per workload axis (Option 3 above) | Each axis has a real seam, but the workload-shape gain over the two-service split is small while the operational cost is large. Six services for one subsystem is over-decomposition. |
The queued-worker seam is the only boundary that aligns workload shape, data ownership, and an existing immutability invariant simultaneously. Take any one of those three away and the case for splitting weakens; with all three, the case is overdetermined.
B.5 Cross-service communication patterns
With two services, three categories of communication exist: gift-processing publishing work to gift-receipting, both services reading legacy data, and both services touching the shared gift-domain database. The first is asynchronous over Service Bus; the second is synchronous HTTP RPC; the third is direct database access governed by the table-ownership rules above.
| Channel | Pattern | Carrying | Rationale |
|---|---|---|---|
| Asynchronous | Azure Service Bus — three queues (receipt-batch, sepa-export, bank-import); sessions enabled on receipt-batch for per-ledger ordering; durable; dead-letter queues feed an admin-retry surface |
RenderAnnualReceiptJob, RunSepaExportJob, RunIchExportJob, ProcessBankStatementUploadJob (with X-Correlation-Id propagation for trace continuity) |
Decouples the batch-mode workload from the interactive request path. Lets each side scale on its own driver. The dead-letter surface gives operators a non-disruptive recovery path for malformed jobs. |
| Synchronous (legacy) | HTTP RPC against legacy .asmx endpoints, wrapped in HttpClientFactory + Polly retry/circuit-breaker policies; one client interface per legacy domain (IPartnerLookupClient, IFinanceLedgerClient) |
Donor identity reads, ledger / period / account / cost-centre reads, GL posting writes | The legacy zone remains authoritative for partner identity and ledger reference data during the strangler-fig coexistence window. Federation rather than replication keeps the two zones consistent without a dual-write problem. |
| Synchronous (new) | None. No HTTP RPC between gift-processing and gift-receipting in v1. | n/a | Each service reads the gift-domain tables it needs from the shared Postgres database directly. The table-ownership rules and the BR-GFT-005 immutability invariant make this safe. Avoiding a synchronous hop between the two new services keeps the queued-worker seam pure — if v2 needs richer cross-service queries, Service Bus request/reply remains available. |
| Database-shared | Single Azure Database for PostgreSQL Flexible Server v16 instance; explicit per-table ownership (Section B.3); EF Core 8 from both services with Dapper for high-volume donor-extract paths | All gift-domain reads/writes; operational-table reads/writes per ownership rules | Database-per-service is operational over-spend for a single bounded context with end-to-end-preserved schema. Shared database with explicit ownership is the right answer at this slice's scale; the boundaries are enforced in code (table grants and EF configuration), not in infrastructure. |
B.6 Federated reads to legacy subsystems
Both new services depend on data that lives in the legacy zone during the strangler-fig coexistence window. Three legacy subsystems are touched: MPartner (donor identity and addresses), MFinance (ledger / period / account / cost-centre, plus GL posting), and the partner-class lookups that drive motivation defaulting on gift detail lines. None of these are migrated as part of this wave; all three remain authoritative on the legacy side.
| Legacy subsystem | Consumed by | Direction | What flows |
|---|---|---|---|
| MPartner (donor identity) | both services | Read — HTTP RPC against legacy .asmx |
IPartnerLookupClient: donor key resolution, partner-class lookup, address fetch for receipt headers, family / person disambiguation |
| MFinance (ledger reference) | both services | Read — HTTP RPC against legacy .asmx |
IFinanceLedgerClient: ledger header read (currency, base / international currency codes), accounting-period validation (open / closed / forward), account / cost-centre catalog reads |
| MFinance (GL posting) | gift-processing-service only | Write — HTTP RPC against legacy .asmx |
IFinanceLedgerClient: GL journal/transaction row submission at gift-batch post time. The only write from the new services into the legacy zone. |
The federation client interfaces (IPartnerLookupClient, IFinanceLedgerClient) are constructed via HttpClientFactory with Polly policies for retry, circuit-breaker, and timeout. Authentication across the boundary uses the legacy TOpenPetraOrgSessionManager session token during the coexistence window, bridged from the new services' Entra ID bearer token by API Management policy. When the legacy MPartner / MFinance subsystems migrate in subsequent waves, the federation client interfaces remain in place and their implementations are swapped from HTTP RPC to direct EF Core access — the calling code in both gift services does not change.
B.7 Independent scaling characteristics
The two services scale on different signals at different rhythms, which is the operational payoff of the split. Each service has its own App Service plan with its own autoscale rules, and the rules do not interfere.
| Service | Scale signal | Scale rhythm | Capacity envelope |
|---|---|---|---|
gift-processing-service |
HTTP request rate; CPU; HTTP queue length | Daily — tracks business-hours user load. Notable surges around year-end fundraising appeals (November / December) and the days following major appeals. | P1v3, 2–6 instances, autoscale on CPU > 70% and HTTP queue length; Npgsql pool MaxPoolSize=100 per instance |
gift-receipting-service |
Service Bus queue depth (per-queue); per-ledger session count for the receipt path | Cyclical — January 15 annual-receipt run produces 10K+ messages within an hour for a mid-sized non-profit; monthly SEPA collection date drives a smaller burst; ad-hoc bank-import days drive the rest. | P1v3, 1–4 instances, autoscale on Service Bus queue depth; Npgsql pool MaxPoolSize=20 per instance (lower than interactive because workers hold transactions longer) |
On a January 15 annual-receipt run, gift-receipting-service may scale from 1 to 4 instances and back over the course of a few hours while gift-processing-service sits at its normal 2-instance daytime baseline. Under the single-service shape (Option 1), the same compute would be shared and the autoscale signal would be ambiguous: is CPU high because users are entering gifts, or because the receipt run is rendering PDFs? The two-service shape removes the ambiguity and lets each side scale honestly on the signal that actually drives its load.
B.8 Service-architecture decision summary
The Finance — Gift Processing slice is decomposed into two services because the workload has two genuinely different shapes — synchronous interactive on one side, asynchronous queued-worker on the other — and collapsing them into a single service would trade away the architectural property that most justifies the modernization (independent scaling of receipt rendering against gift entry). The queued-worker boundary is the natural seam, and the posted-batch immutability invariant (BR-GFT-005) is what makes the seam safe: receipting reads only from posted batches, posted batches do not change, so there is no read-your-own-writes problem to defend against. Cross-service communication is asynchronous over Azure Service Bus for the work itself and synchronous HTTP RPC against the legacy zone for federated reads of partner and ledger reference data; there is no synchronous RPC between the two new services in v1. This is the slice's load-bearing structural decision — every other architectural choice in this report (the Service Bus queues, the worker scaling envelope, the operational tables on the receipting side, the federation client interfaces) follows from it.
Appendix C: Deployment and Infrastructure
This appendix walks through the deployable Azure infrastructure for the Finance — Gift Processing slice of Petra. The artifact tree under infrastructure/bicep/ provisions an Azure App Service plan, a containerised .NET 8 site for gift-processing-service, a PostgreSQL Flexible Server, a Service Bus namespace with three queues, an Azure Container Registry, a Key Vault, an Application Insights component backed by a Log Analytics workspace, and the role assignments that wire the App Service's managed identity to the registry, the Service Bus, and the vault. Every parameter that varies between a demo posture and a production posture is exposed on main.bicep — the production shift is a parameter override, not a template rewrite.
migration-artifacts-petra-azure-app-service-002/. The intent here is to give stakeholders a guided tour of the deployment story end-to-end — topology, prerequisites, orchestration, image lifecycle, slice-boundary policy, deploy commands, demo-vs-production shifts, observability wiring, and rollback — before the artefact tree is handed to a deployment engineer.
C.1 Deployment topology
A single subscription-scoped Bicep deployment creates one resource group (default petra-gift-002-rg in westeurope) and provisions nine Azure resources inside it. The App Service runs the Gift Processing container pulled from the registry; the App Service's system-assigned managed identity is the only principal used at runtime — no shared keys, no admin credentials. Connection strings live in Key Vault and are surfaced to the application as appSettings Key Vault references.
| Resource | SKU / Tier (demo) | Role in the slice |
|---|---|---|
| App Service Plan | P1v3 Linux, capacity 1 | Compute substrate for the containerised .NET 8 site. |
| App Service (site) | Linux container | Runs gift-processing-service, image pulled from ACR. System-assigned managed identity. Health probe path /health. |
| PostgreSQL Flexible Server | Standard_B1ms (Burstable), v16 | Database petra_gift. Holds the gift slice's schema; cross-slice FKs are dropped (see C.5). |
| Service Bus namespace | Standard | Three queues: receipt-batch (sessions enabled), sepa-export, bank-import. Async substrate between gift processing and downstream consumers. |
| Azure Container Registry | Basic, admin user disabled | Image source for the App Service. Pulls authenticate via the App Service's managed identity (AcrPull role). |
| Key Vault | Standard, RBAC mode | Holds the Postgres connection string and the App Insights connection string. Read access via managed identity (Key Vault Secrets User role). |
| Application Insights | Workspace-based | Receives Serilog and OpenTelemetry telemetry from the App Service. Backed by the Log Analytics workspace below. |
| Log Analytics workspace | PerGB2018, 30-day retention | Underlying log store for Application Insights and the App Service's diagnostic settings. |
| Role assignments | 3 roles on the App Service MI | AcrPull on the registry, Azure Service Bus Data Owner on the namespace, Key Vault Secrets User on the vault. |
The diagram below shows how those resources fit together. The App Service is the only inbound endpoint; everything else is reached via the App Service's managed identity over Azure-internal networking (or, in the demo posture, the public Postgres and Service Bus endpoints behind RBAC).
flowchart TB
Internet[Internet HTTPS] --> AppService[App Service - gift-processing-service]
AppService --> Plan[App Service Plan P1v3 Linux]
AppService --> ACR[Azure Container Registry]
AppService --> KV[Key Vault]
AppService --> SB[Service Bus Namespace]
AppService --> PG[PostgreSQL Flexible Server]
AppService --> AI[Application Insights]
AI --> LAW[Log Analytics Workspace]
KV --> PGSecret[Postgres Connection Secret]
KV --> AISecret[App Insights Connection Secret]
SB --> QReceipt[receipt-batch queue]
SB --> QSepa[sepa-export queue]
SB --> QBank[bank-import queue]
C.2 Pre-deploy prerequisites
Before running the deployment, an operator needs the following in place:
- Azure CLI 2.55+ with the Bicep extension.
az bicep installbootstraps the compiler if it is missing. The artefact ships only.bicepsource — the CLI compiles it on the fly duringaz deployment. - Azure subscription with Owner or Contributor + User Access Administrator role. The deployment creates role assignments on the App Service's managed identity, which requires authority to write to RBAC.
az loginfollowed byaz account set --subscription <id>. - Resource group naming. The default is
${prefix}-rgwhereprefixispetra-gift-002. OverrideresourceGroupNameinparameters.jsonif your tenant has a fixed naming convention. - Region. Default
westeurope— chosen because Petra's primary user base is European NGOs. Any Azure region that supports App Service Linux + Postgres Flexible Server + Service Bus is interchangeable. - One-time secret bootstrap.
parameters.jsonshipspostgresAdminPasswordas the literal placeholder__REPLACE_AT_DEPLOY__. Override it on theaz deploymentcommand line, supply it from a CI secret, or rewrite the parameter as a Key Vault reference. The Application Insights connection string and the Postgres connection string are composed inside the Bicep at deploy time and stored automatically into Key Vault — no manual entry needed. - Container image. The Bicep provisions the registry but does not build the image. The image must exist in ACR before the App Service module runs. See C.4 for the bootstrap pattern.
C.3 The Bicep orchestration
infrastructure/bicep/main.bicep is a subscription-scoped orchestrator: it creates the resource group, then composes nine resource-group-scoped modules under infrastructure/bicep/modules/. Each module is single-purpose — one Azure resource, one set of params, one set of outputs — which keeps each file readable and lets the orchestrator wire them in dependency order.
| Module | Produces / Consumes |
|---|---|
modules/log-analytics.bicep |
Produces workspaceId. No upstream dependencies. |
modules/app-insights.bicep |
Consumes workspaceId. Produces connectionString. |
modules/postgres-flexible.bicep |
Produces fqdn. Consumes admin login + password params. |
modules/service-bus.bicep |
Produces namespaceName and queueNames. Consumes the queue list and sessions list. |
modules/key-vault.bicep |
Stores the composed Postgres connection string and the App Insights connection string. Produces postgresSecretUri, applicationInsightsSecretUri, and vaultName. |
modules/acr.bicep |
Produces loginServer and registryName. |
modules/app-service-plan.bicep |
Produces planId. Consumes SKU + capacity. |
modules/app-service-linux-dotnet.bicep |
Produces appServiceUrl and principalId (the managed identity). Consumes plan id, ACR login server, image name + tag, both Key Vault secret URIs, the Service Bus namespace name, and the queue list. |
modules/role-assignments.bicep |
Grants AcrPull, Azure Service Bus Data Owner, Key Vault Secrets User to the App Service's principalId. Runs last because it references resources from every preceding module. |
The dependency chain is Kahn-ordered: log-analytics has no inputs, so it runs first; app-insights waits on the workspace id; key-vault waits on Postgres and app-insights so it can store both connection strings; the App Service site waits on the plan, the registry, the vault, and the Service Bus; role-assignments runs last because the App Service's managed identity principalId only exists once the site has been created. Bicep's module graph derives this order automatically from output references — the orchestrator does not need dependsOn declarations.
C.4 Container image build and push
The Bicep provisions the registry; CI is responsible for building the image and pushing it. The Dockerfile lives at services/gift-processing-service/Dockerfile — a multi-stage build that compiles on mcr.microsoft.com/dotnet/sdk:8.0 and ships the runtime layer on mcr.microsoft.com/dotnet/aspnet:8.0, dropping privileges to a non-root sage user before the entrypoint.
# 1. Build
docker build \
-t petragift002acr.azurecr.io/gift-processing-service:latest \
./services/gift-processing-service
# 2. Authenticate to the registry
az acr login --name petragift002acr
# 3. Push
docker push petragift002acr.azurecr.io/gift-processing-service:latest
Image tag versioning. The default tag is latest — convenient for the demo, where re-pushing the tag is the reset mechanism. Production CI should pin the tag to the build's git SHA (e.g., gift-processing-service:a1b2c3d) so a redeploy is reproducible and a rollback is just a parameter change. The Bicep exposes imageTag on main.bicep; flipping it from latest to a SHA is a one-line override.
Bootstrap pattern. Because the App Service site references the image at create time, the very first deployment cannot succeed until the image exists. The recommended pattern is: run the deployment once (the App Service will land in a Configuring then Failed state because the image pull 404s), push the image, then re-run the same deployment — the site will pick up the now-present image and reach Running. From the second deploy onwards the order is purely image-push then deploy.
C.5 Slice-boundary FK policy
Petra's gift slice has 17 cross-slice foreign keys — columns that point at reference tables outside the Finance — Gift Processing scope (country codes, partner classifications, payment methods, and so on). The slice's audit lives at database/DROPPED_FKs.md. The chosen policy — drop-constraints — drops those FK constraints in the new slice's PostgreSQL schema while leaving the columns themselves intact.
| Source table | Source column | Target (out of slice) | FK action |
|---|---|---|---|
a_currency | p_country_code_c | p_country | dropped |
p_partner | p_partner_class_c | p_partner_classes | dropped |
p_partner | p_addressee_type_code_c | p_addressee_type | dropped |
p_partner | p_language_code_c | p_language | dropped |
p_partner | p_acquisition_code_c | p_acquisition | dropped |
p_partner | p_status_code_c | p_partner_status | dropped |
p_partner | p_first_contact_code_c | p_first_contact | dropped |
a_ledger | a_tax_type_code_c | a_tax_type | dropped |
a_ledger | a_country_code_c | p_country | dropped |
a_gift | a_method_of_giving_code_c | a_method_of_giving | dropped |
a_gift | a_method_of_payment_code_c | a_method_of_payment | dropped |
a_gift_detail | p_mailing_code_c | p_mailing | dropped |
a_recurring_gift | a_method_of_giving_code_c | a_method_of_giving | dropped |
a_recurring_gift | a_method_of_payment_code_c | a_method_of_payment | dropped |
a_recurring_gift_detail | p_mailing_code_c | p_mailing | dropped |
a_account | a_budget_type_code_c | a_budget_type | dropped |
a_account | p_banking_details_key_i | p_banking_details | dropped |
Why drop, not stub? During the migration window the legacy Petra database is still live and still owns those reference tables. The legacy system is the system of record for country codes, partner classes, payment methods. If the new slice's schema duplicated those tables and enforced FKs against the local copies, every reference data update in the legacy system would have to be replicated synchronously to the new slice or risk insert failures. That is double-bookkeeping; drop-constraints avoids it. Application-layer validation in gift-processing-service verifies referential integrity before insert — reading the legacy reference set when needed — and the dropped FKs can be re-introduced when the legacy system retires and the reference tables migrate into this slice.
The alternative. A greenfield rewrite where there is no legacy Petra DB to coexist with would use the stub-targets policy instead: synthesise empty target tables in the new slice's schema and keep the FKs enforced. The engagement-level configuration exposes this policy as a setting so the same artefact-generation pipeline can produce either output without code changes.
C.6 Deploy command sequence
Once the prerequisites in C.2 are satisfied and the image is built per C.4, the full deployment is six commands:
# 1. Authenticate the CLI to Azure.
az login
# 2. Pin the active subscription.
az account set --subscription <subscription-id>
# 3. Build the container image (multi-stage Dockerfile under services/).
docker build \
-t petragift002acr.azurecr.io/gift-processing-service:latest \
./services/gift-processing-service
# 4. Authenticate Docker to ACR (uses the az login token).
az acr login --name petragift002acr
# 5. Push the image so the App Service can pull it on first start.
docker push petragift002acr.azurecr.io/gift-processing-service:latest
# 6. Provision the Azure resources via the Bicep orchestrator.
PG_PWD='SomeStrongPasswordHere!'
az deployment sub create \
--location westeurope \
--template-file infrastructure/bicep/main.bicep \
--parameters infrastructure/bicep/parameters.json \
--parameters postgresAdminPassword="${PG_PWD}"
Step 1 establishes the CLI's Azure identity. Step 2 fixes which subscription receives the resources — important when the operator has access to several. Steps 3-5 produce and publish the container image; the App Service module in step 6 will fail to start the site if the image is absent. Step 6 runs the orchestrator: az deployment sub create targets the subscription scope (because main.bicep declares targetScope = 'subscription') and lets the resource group come into existence as part of the same deployment. The Postgres password is passed as a separate --parameters override so it stays out of parameters.json.
Optional pre-deploy validation. az bicep build --file infrastructure/bicep/main.bicep performs a syntactic compile of the orchestrator and every module without touching Azure. az deployment sub what-if --location westeurope --template-file ... shows the resource diff that would be applied without applying it.
Post-deploy verification. Once the deployment reports Succeeded, the App Service URL is in the deployment outputs. A health probe confirms the site is live:
curl https://petra-gift-002-gift-processing-service.azurewebsites.net/health
# Expected: 200 OK with body "Healthy"
C.7 Demo posture vs production posture
The defaults in parameters.json are tuned for a demo: cheap, single-instance, public-network. Production requires a parameter shift on the same main.bicep — not a template rewrite. Every value below is a parameter on main.bicep. A sibling parameters.production.json carrying these values is the standard pattern.
| Concern | Demo default | Production-recommended |
|---|---|---|
| Postgres SKU | Standard_B1ms (Burstable) |
Standard_D2ds_v5 (General Purpose) |
| Postgres HA | Disabled (single AZ) |
ZoneRedundant |
| Postgres network | Public + Azure-services firewall rule | VNet integration + Private Endpoint, publicNetworkAccess: Disabled |
| Service Bus tier | Standard |
Premium (dedicated capacity, VNet support, larger messages) |
| Service Bus access | Namespace-level Data Owner role | Per-queue Data Sender / Receiver roles |
| Key Vault retention | 7 days soft-delete, purge protection off | 90 days soft-delete, enablePurgeProtection: true |
| Key Vault network | Public (Allow) |
Private Endpoint + publicNetworkAccess: Disabled |
| ACR SKU | Basic |
Premium (geo-replication, Private Endpoint, content scanning) |
| App Service plan | P1v3, capacity 1 |
P2v3 or higher, capacity 2+, zoneRedundant: true, autoscale rules |
| Image tag | latest |
Pinned SHA or semver, emitted by CI |
C.8 Observability wiring
The App Service streams telemetry to Application Insights, which in turn lands its data in the shared Log Analytics workspace. Three concerns cover the runtime observability story:
- Structured logs. The .NET 8 service uses Serilog with the
Serilog.Sinks.ApplicationInsightssink wired inProgram.cs. Every log line carries the App Insightsoperation_Idand the OpenTelemetry trace + span ids, so a single business event can be queried across logs and traces by a shared correlation id. - Distributed traces.
Azure.Monitor.OpenTelemetry.AspNetCoreis the one-line bootstrap (builder.Services.AddOpenTelemetry().UseAzureMonitor()). It emits OTLP to the Application Insights connection string read from Key Vault. ASP.NET Core, HTTP client, and Service Bus instrumentation come along for free; database calls are instrumented via theNpgsql.OpenTelemetryadd-on. - Health probes. The service exposes
/health(liveness) and/ready(readiness, checks the Postgres connection and the Service Bus connection). The Dockerfile'sHEALTHCHECKdirective polls/health; App Service's built-in health-check feature (configured on the App Service module viahealthCheckPath) polls the same endpoint and removes unhealthy instances from rotation.
Because both the Postgres connection string and the App Insights connection string are stored in Key Vault and surfaced as appSettings Key Vault references, an operator can rotate either without redeploying the App Service — bumping the secret version triggers an automatic refresh on the site.
C.9 Rollback story
Two complementary rollback options cover most failure modes. First, the Bicep deployment is idempotent: re-running az deployment sub create with the previous parameters file (for example, the previous imageTag) reverts the App Service to the prior image. The other resources are unchanged because their inputs are unchanged — Bicep's incremental deployment mode is a no-op on resources whose desired state matches the current state. Second, App Service supports deployment slots: a production slot and a staging slot share the plan, and a swap flips traffic between them in seconds. The v1 Bicep deploys to a single slot; adding a staging slot is a small parameter extension on app-service-linux-dotnet.bicep, and the swap is then a one-command rollback (az webapp deployment slot swap). Either path keeps the rollback inside the same artefact tree — no separate restore tooling, no out-of-band scripts.