You are viewing an old version of this page. View the current version.

Compare with Current View Page History

Version 1 Current »

3.1 FR-001: Batch File Upload

Requirement: Users shall upload batch invoice files through a RESTful API with automatic vendor format detection, file validation, and storage in organization-specific blob containers with month/day hierarchy.

Priority: HIGH

API Endpoint: POST /organizations/{organizationId}/batches

Request Format:

POST /v1/organizations/123e4567-e89b-12d3-a456-426614174000/batches HTTP/1.1
Host: api.egflow.com
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbG...
Content-Type: multipart/form-data; boundary=----WebKitFormBoundary

------WebKitFormBoundary
Content-Disposition: form-data; name="file"; filename="invoices_nov.xml"
Content-Type: application/xml

<?xml version="1.0" encoding="UTF-8"?>
<InvoiceBatch xmlns="urn:ediel:se:electricity:invoice:1.0">
  ...
</InvoiceBatch>
------WebKitFormBoundary
Content-Disposition: form-data; name="metadata"
Content-Type: application/json

{
  "batchName": "Invoice_November_2025",
  "priority": "normal"
}
------WebKitFormBoundary--


 

Response Format (201 Created):

{
  "success": true,
  "data": {
    "batchId": "550e8400-e29b-41d4-a716-446655440000",
    "organizationId": "123e4567-e89b-12d3-a456-426614174000",
    "status": "uploaded",
    "uploadedAt": "2025-11-21T10:30:00Z",
    "fileInfo": {
      "fileName": "invoices_nov.xml",
      "fileSize": 15728640,
      "checksum": "sha256:a3d5e7f9b2c4d6e8...",
      "detectedFormat": "GASEL"
    },
    "blobPath": "acme-batches-2025/11/21/550e8400.../source.xml"
  },
  "metadata": {
    "timestamp": "2025-11-21T10:30:00Z",
    "requestId": "req-abc-123",
    "apiVersion": "1.0"
  }
}


 

Blob Storage Path Pattern (Updated per Nov 21 decision):

{org-id}-batches-{year}/{month}/{day}/{batch-id}/source.xml


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Accepts XML files up to 100MBUpload 100MB XML filegasel_100mb.xml201 Created
Validates file is well-formed XMLUpload malformed XMLinvalid.xml400 INVALID_XML
Stores in org-specific container with month/day pathUpload, verify blob pathvalid.xmlPath: {org}/2025/11/21/{id}/source.xml
Returns unique UUID batch IDUpload, check batchId formatvalid.xmlValid UUID v4
Detects GASEL formatUpload GASEL XMLgasel_sample.xmldetectedFormat: "GASEL"
Detects XELLENT formatUpload XELLENT XMLxellent_sample.xmldetectedFormat: "XELLENT"
Detects ZYNERGY formatUpload ZYNERGY XMLzynergy_sample.xmldetectedFormat: "ZYNERGY"
Calculates SHA-256 checksumUpload, verify checksumvalid.xmlChecksum matches file
Requires Batch Operator roleUpload without rolevalid.xml403 ACCESS_DENIED
Rate limited (10 uploads/hour/org)Upload 11 files in 1 hourvalid.xml x1111th returns 429
File size > 100MB rejectedUpload 101MB filelarge.xml413 FILE_TOO_LARGE
Non-XML files rejectedUpload PDF fileinvoice.pdf415 UNSUPPORTED_FORMAT

Validation Rules:

FieldRuleError CodeError Message
fileRequiredVALIDATION_ERRORFile is required
file.size1KB ≤ size ≤ 100MBFILE_TOO_LARGEFile must be between 1KB and 100MB
file.contentTypeMust be application/xml or text/xmlINVALID_CONTENT_TYPEFile must be XML format
file.contentWell-formed XML (parseable)INVALID_XMLXML file is not well-formed. Line {line}, Column {column}: {error}
metadata.batchName1-255 characters, no path separatorsVALIDATION_ERRORBatch name must be 1-255 characters without / or \
metadata.priorityMust be "normal" or "high"VALIDATION_ERRORPriority must be 'normal' or 'high'

Error Scenarios:

ScenarioHTTPError CodeMessageDetails
File too large413FILE_TOO_LARGEFile exceeds 100MB limit{ "fileSize": 105906176, "limit": 104857600 }
Invalid XML400INVALID_XMLXML file is not well-formed{ "line": 142, "column": 23, "error": "Unexpected end tag" }
Missing token401UNAUTHORIZEDMissing or invalid authentication token{ "suggestion": "Include Authorization: Bearer {token} header" }
Insufficient permissions403ACCESS_DENIEDUser does not have Batch Operator role{ "requiredRole": "Batch Operator", "userRoles": ["Read-Only"] }
Rate limit exceeded429RATE_LIMIT_EXCEEDEDToo many batch uploads. Limit: 10 per hour{ "limit": 10, "window": "1 hour", "retryAfter": "2025-11-21T11:30:00Z" }


3.2 FR-002: Batch Processing Initiation

Requirement: Users shall initiate batch processing through API, which enqueues the batch to batch-upload-queue for asynchronous processing by ParserService.

Priority: HIGH

API Endpoint: POST /organizations/{organizationId}/batches/{batchId}/start

Request Format:

POST /v1/organizations/{orgId}/batches/{batchId}/start HTTP/1.1
Authorization: Bearer {token}
Content-Type: application/json

{
  "validationMode": "strict"
}


 

Response Format (202 Accepted):

{
  "success": true,
  "data": {
    "batchId": "550e8400-e29b-41d4-a716-446655440000",
    "status": "queued",
    "queuedAt": "2025-11-21T10:35:00Z",
    "estimatedProcessingTime": "15-30 minutes",
    "queuePosition": 2,
    "queueName": "batch-upload-queue"
  }
}


 

Processing Flow:

1. Validate batch exists and status is "uploaded"
2. Create message in batch-upload-queue with batch metadata
3. Update batch status to "queued" in blob metadata
4. Return 202 Accepted with queue position
5. ParserService picks up message asynchronously


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Batch must be in "uploaded" statusStart already-processing batchprocessing-batch-id409 CONFLICT
Creates message in batch-upload-queueVerify queue message createdvalid-batch-idMessage present
Updates batch status to "queued"Check batch metadata after startvalid-batch-idstatus: "queued"
Returns estimated time based on queueCheck with empty vs full queuevariousTime varies with queue depth
Idempotent (duplicate calls safe)Call /start twicevalid-batch-idBoth return 202, one processes
Returns current queue positionVerify position accuracyvalid-batch-idPosition matches queue
Requires Batch Operator roleCall without rolevalid-batch-id403 ACCESS_DENIED
Supports "strict" and "lenient" validationSet validationMode=lenientvalid-batch-idMode stored in message
Queue full returns 503Start when queue depth >10000valid-batch-id503 SERVICE_UNAVAILABLE

Validation Rules:

CheckRuleError CodeHTTPAction
Batch existsMust exist in blob storageRESOURCE_NOT_FOUND404Return error immediately
Batch ownershipMust belong to organization in pathACCESS_DENIED403Return error immediately
Batch statusMust be "uploaded", not "queued"/"processing"/"completed"PROCESSING_ERROR422Return error with current status
Organization activeOrganization.isActive = trueORGANIZATION_INACTIVE422Return error
Queue capacityQueue depth < 10,000SERVICE_UNAVAILABLE503Return with retry-after
User permissionUser has BatchOperator or higher roleACCESS_DENIED403Return error
Validation modeMust be "strict" or "lenient"VALIDATION_ERROR400Return error with allowed values

Error Scenarios:

ScenarioHTTPError CodeMessageDetailsUser Action
Batch not found404RESOURCE_NOT_FOUNDBatch does not exist{ "batchId": "{id}" }Verify batch ID
Already processing409CONFLICTBatch is already processing{ "currentStatus": "processing", "startedAt": "2025-11-21T10:00:00Z" }Wait for completion or cancel
Invalid status422PROCESSING_ERRORBatch cannot be started from current status{ "currentStatus": "completed", "allowedStatuses": ["uploaded"] }Re-upload batch
Queue at capacity503SERVICE_UNAVAILABLESystem at capacity, retry later{ "queueDepth": 10500, "retryAfter": "2025-11-21T11:00:00Z" }Schedule for off-peak


3.3 FR-003: Parser Service (XML → JSON Transformation)

Requirement: The ParserService shall listen to batch-upload-queue, download batch XML from blob storage, detect vendor format, validate against XSD schema, parse to individual invoices, transform to canonical JSON format, and enqueue to batch-items-queue in groups of 32.

Priority: CRITICAL

Service Specifications:

Trigger: Message in batch-upload-queue
Input: Batch ID from queue message
Output: Individual JSON files in blob + messages in batch-items-queue

Processing Steps:

1. Dequeue message from batch-upload-queue
2. Download batch XML from blob: {org}-batches-{year}/{month}/{day}/{batch-id}/source.xml
3. Detect vendor format (namespace + structure analysis):
   - urn:ediel → GASEL
   - oio.dk → XELLENT
   - Zynergy → ZYNERGY
4. Load vendor-specific schema mapping from: {org}-data/schemas/{vendor}-mapping.json
5. Validate XML against vendor XSD schema
6. Parse XML using XPath expressions from mapping
7. Transform each invoice to canonical JSON format
8. Store individual JSON files: {org}-invoices-{year}/{month}/{day}/{invoice-id}.json
9. Group invoices into batches of 32
10. Enqueue each 32-item batch to batch-items-queue
11. Update batch metadata: totalItems, vendor info, status="processing"
12. Delete message from batch-upload-queue (on success)
13. On error: Retry (3x with backoff) or move to poison queue


 

Canonical JSON Schema (Output Format):

{
  "invoiceId": "uuid",
  "invoiceNumber": "2025-11-001",
  "invoiceDate": "2025-11-06",
  "dueDate": "2025-11-20",
  "currency": "SEK",
  "periodStart": "2025-10-01",
  "periodEnd": "2025-10-31",
  
  "customer": {
    "customerId": "020624-2380",
    "fullName": "Medeni Schröder",
    "firstName": null,
    "lastName": null,
    "email": "muntaser.af@zavann.net",
    "phone": "09193538799",
    "address": {
      "street": "Strandbo 63B",
      "houseNumber": null,
      "apartment": null,
      "city": "Växjö",
      "postalCode": "352 58",
      "country": "SE"
    },
    "taxIdentifier": "020624-2380",
    "customerType": "private"
  },
  
  "invoiceDetails": {
    "subTotal": 599.42,
    "taxAmount": 149.86,
    "totalAmount": 749.28,
    "lineItems": [
      {
        "lineNumber": 1,
        "description": "Elförbrukning - Fast pris",
        "quantity": 420,
        "unit": "KWH",
        "unitPrice": 1.026,
        "lineAmount": 430.92,
        "taxRate": 25.0,
        "taxAmount": 107.73,
        "category": "electricity"
      },
      {
        "lineNumber": 2,
        "description": "Månadsavgift",
        "quantity": 1,
        "unit": "MON",
        "unitPrice": 15.20,
        "lineAmount": 15.20,
        "taxRate": 25.0,
        "taxAmount": 3.80,
        "category": "fee"
      }
    ]
  },
  
  "delivery": {
    "meteringPointId": "735999756427205424",
    "gridArea": "SE4",
    "gridOwner": "Växjö Energi Elnät AB",
    "previousReading": {
      "date": "2025-09-30",
      "value": 10580,
      "type": "Actual"
    },
    "currentReading": {
      "date": "2025-10-31",
      "value": 11000,
      "type": "Actual"
    },
    "consumption": 420,
    "consumptionUnit": "kWh"
  },
  
  "payment": {
    "paymentId": "202511001",
    "paymentMethod": "Bankgiro",
    "bankAccount": "168-6039",
    "dueDate": "2025-11-20"
  },
  
  "sourceMetadata": {
    "vendorCode": "GASEL",
    "vendorVersion": "1.0",
    "originalBatchId": "BATCH2025110600001",
    "originalInvoiceId": "2025-11-001",
    "contractReference": "CON-2024-001",
    "customFields": {
      "productCode": "telinet_fixed",
      "productName": "Fast Pris Vintersäkra",
      "taxClassification": "Normal"
    },
    "parsedAt": "2025-11-21T10:35:45Z"
  }
}


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Listens to batch-upload-queueSend message, verify service picks upQueue messageMessage dequeued within 30s
Downloads batch XML from blobVerify file download loggedBatch in blobFile downloaded successfully
Detects GASEL format (100% accuracy)Test with 50 GASEL samplesgasel_*.xmlAll detected as GASEL
Detects XELLENT format (100% accuracy)Test with 50 XELLENT samplesxellent_*.xmlAll detected as XELLENT
Detects ZYNERGY format (100% accuracy)Test with 50 ZYNERGY sampleszynergy_*.xmlAll detected as ZYNERGY
Loads correct schema mappingVerify mapping file loadedgasel_sample.xmlgasel-mapping.json loaded
Validates XML against XSDUpload invalid GASEL XMLinvalid_gasel.xmlValidation errors in batch metadata
Parses GASEL using XPath mappingsParse GASEL, verify JSON fieldsgasel_sample.xmlAll fields extracted
Parses XELLENT with namespace handlingParse XELLENT (com:, main: prefixes)xellent_sample.xmlAll fields extracted
Parses ZYNERGY nested structureParse ZYNERGYzynergy_sample.xmlAll fields extracted
Transforms to canonical JSONVerify JSON schema complianceAll vendorsAll pass schema validation
Stores JSON: {org}-invoices-{year}/{month}/{day}/{id}.jsonCheck blob pathParsed invoiceCorrect path used
Groups into 32-item batchesParse 100 invoices, count queue messages100-invoice batch4 messages (32+32+32+4)
Enqueues to batch-items-queueVerify messages in queueParsed batchMessages present
Updates batch metadataCheck metadata after parsingParsed batchtotalItems, vendorCode set
Deletes from batch-upload-queue on successVerify message removedSuccessful parseMessage gone
Retries 3x on transient errorsForce blob download errorFailing batch3 retries logged
Moves to poison queue after 3 failuresForce permanent errorFailing batchMessage in poison queue
Parsing completes within 2 minutes for 10K batchPerformance test10K-invoice XML≤ 120 seconds

Vendor-Specific Parsing Rules:

GASEL Format:

Required Elements (validation fails if missing):
- BatchHeader/InterchangeID
- BatchHeader/TotalInvoiceCount
- SupplierParty/PartyName
- Invoice/InvoiceHeader/InvoiceNumber
- Invoice/CustomerParty/PartyName
- Invoice/MonetarySummary/PayableAmount

Optional Elements (null if missing):
- Invoice/DeliveryLocation/MeteringPointID
- Invoice/ContractDetails/ContractID
- Invoice/CustomerParty/Contact/ElectronicMail
- Invoice/CustomerParty/Contact/Telephone

Date Format: ISO 8601 (YYYY-MM-DD)
Amount Format: Decimal with 2 places, period as separator
Namespace: urn:ediel:se:electricity:invoice:1.0


 

XELLENT Format:

Required Elements:
- BatchHeader/BatchID
- com:ID (invoice number)
- com:IssueDate
- com:BuyerParty/com:PartyName/com:Name
- com:LegalTotals/com:ToBePaidTotalAmount

Optional Elements:
- com:BuyerParty/com:ContactInformation/@E-Mail
- com:BuyerParty/com:Address

Special Handling:
- Multiple namespaces (com:, main:, fsv:)
- Amount format: "1 245,00" (space separator, comma decimal)
- Must normalize to standard decimal format

Namespace: http://rep.oio.dk/ubl/xml/schemas/0p71/pie/


 

ZYNERGY Format:

Required Elements:
- BatchHeader/BatchId
- InvoiceData/InvoiceNumber
- Customer/ReadOnlyFullName
- InvoiceData/InvoiceAmount

Optional Elements:
- Customer/FirstName and LastName (if ReadOnlyFullName empty)
- InvoiceAddress/EmailAddress
- VAT details

Special Handling:
- Nested structure (Invoice > Customer, InvoiceData, InvoiceAddress, VAT)
- Multiple company references (CompaniesId throughout)
- InvoiceAmount vs InvoiceBalance distinction

Namespace: http://eg.dk/Zynergy/1.0/invoice.xsd


 

Dependencies:

  • Azure Storage Queue: batch-upload-queue
  • Vendor schema mappings in blob storage
  • XSD schema files for validation
  • Canonical JSON schema definition
  • Error handling and retry infrastructure

Risks & Mitigation:

RiskLikelihoodImpactMitigation StrategyOwner
Large XML file memory issues (>50MB)MEDIUMHIGH- Stream-based parsing (XmlReader, not XDocument)
- Process invoices incrementally
- Worker memory limit monitoring
- File size alerts at 75MB
Technical Architect
Parsing performance bottleneckMEDIUMHIGH- Parallel XPath evaluation where possible
- Compiled XPath expressions cached
- POC: parse 10K invoices in <2 minutes
- Horizontal scaling of ParserService
Technical Architect
XSD validation performanceLOWMEDIUM- Cache compiled XSD schemas
- Make validation optional in lenient mode
- Async validation (don't block parsing)
Technical Architect
Vendor-specific edge casesHIGHMEDIUM- Extensive test suite per vendor (50+ samples)
- Error collection from production
- Vendor liaison for unclear cases
- Lenient mode for known variations
Product Owner


3.4 FR-004: Document Generator Service (JSON → HTML → PDF)

Requirement: The DocumentGeneratorService (based on existing zyn-DocumentGenerator) shall listen to batch-items-queue, load Handlebars templates, render HTML with invoice data, generate PDFs using Playwright, and store documents in blob storage with month/day hierarchy.

Priority: CRITICAL

Service Specifications:

Trigger: Message in batch-items-queue (contains 32 invoice references)
Input: Batch of 32 invoice IDs and organization ID
Output: PDF + HTML files in blob storage, messages in distribution routing queues

Processing Steps (per 32-item batch):

1. Dequeue message from batch-items-queue
2. Acquire blob lease: {org}-batches-{year}/{month}/{day}/{batch-id}/locks/{worker-id}.lock
3. For each of 32 invoices:
   a. Download JSON: {org}-invoices-{year}/{month}/{day}/{invoice-id}.json
   b. Load organization config: {org}-data/organization.json
   c. Determine template category from distribution type
   d. Load Handlebars template: {org}-data/templates/{category}/active.html
   e. Load organization branding (logo from blob URL, colors, fonts)
   f. Compile Handlebars template (cache compiled version)
   g. Render HTML with invoice data + branding
   h. Generate PDF from HTML using Playwright (headless Chromium)
   i. Store HTML: {org}-invoices-{year}/{month}/{day}/{invoice-id}.html
   j. Store PDF: {org}-invoices-{year}/{month}/{day}/{invoice-id}.pdf
   k. Update invoice JSON metadata with file paths and render timestamp
   l. Determine distribution method from invoice data
   m. Enqueue to appropriate queue:
      - Mail (postal) → postal-bulk-queue (processed in bulk)
      - Email → email-queue
      - SMS (future) → sms-queue
      - Kivra (future) → kivra-queue
      - E-faktura (future) → efaktura-queue
4. Release blob lease
5. Update batch metadata: processedItems += 32
6. Delete message from batch-items-queue
7. On error: Retry (3x) or poison queue


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Listens to batch-items-queueSend message, verify pickupQueue messageDequeued within 30s
Processes 32 invoices per messageSend 32-item batch, count outputs32 invoices32 PDFs generated
Acquires blob lease before processingCheck lease on blobValid batchLease acquired
Downloads invoice JSON from correct pathVerify download loggedInvoice JSONCorrect path: {org}/2025/11/21/{id}.json
Loads organization templateVerify template file accessedOrg with templateTemplate loaded
Determines template category correctlyInvoice → "invoice" template, Letter → "confirmation"Various typesCorrect template used
Compiles Handlebars templateRender with variablesTemplate with {{invoiceNumber}}Number inserted
Caches compiled templates (24h)Render same template twiceSame templateSecond render faster
Renders HTML with Swedish charactersRender with åäöSwedish invoiceCharacters correct
Generates PDF with PlaywrightConvert HTML to PDFRendered HTMLPDF created, A4 format
PDF includes organization brandingCheck PDF for logo, colorsBranded templateBranding visible
Stores HTML in correct blob pathVerify pathGenerated HTML{org}/invoices/2025/11/21/{id}.html
Stores PDF in correct blob pathVerify pathGenerated PDF{org}/invoices/2025/11/21/{id}.pdf
Updates invoice metadata JSONCheck metadata after renderProcessed invoicefileReferences populated
Determines distribution methodCheck routing logicVarious configsCorrect queue selected
Enqueues to postal-bulk-queue for mailInvoice with postal deliveryMail invoiceMessage in postal queue
Enqueues to email-queue for emailInvoice with email deliveryEmail invoiceMessage in email queue
Releases blob lease on completionVerify lease releasedProcessed batchLease gone
Updates batch statisticsCheck batch metadataProcessed batchprocessedItems incremented
Rendering within 2 seconds per invoice (p95)Performance test 1000 invoicesVariousp95 ≤ 2 seconds
PDF generation within 5 seconds per invoice (p95)Performance test 1000 PDFsVariousp95 ≤ 5 seconds
Retries on transient errorsForce blob errorFailing invoice3 retries attempted
Moves to poison queue after 3 failuresForce permanent errorFailing invoicePoison queue message

Handlebars Template Example:

<!DOCTYPE html>
<html lang="sv">
<head>
    <meta charset="UTF-8">
    <title>Faktura {{invoiceNumber}}</title>
    <style>
        body { font-family: {{organization.fontFamily}}; }
        .header { background-color: {{organization.primaryColor}}; color: white; padding: 20px; }
        .logo { max-width: 200px; }
    </style>
</head>
<body>
    <div class="header">
        <img src="{{organization.logoUrl}}" alt="{{organization.displayName}}" class="logo" />
        <h1>Faktura {{invoiceNumber}}</h1>
    </div>
    
    <div class="customer-info">
        <h2>Kund</h2>
        <p><strong>{{customer.fullName}}</strong></p>
        <p>{{customer.address.street}}</p>
        <p>{{customer.address.postalCode}} {{customer.address.city}}</p>
    </div>
    
    <div class="invoice-details">
        <p><strong>Fakturadatum:</strong> {{invoiceDate}}</p>
        <p><strong>Förfallodatum:</strong> {{dueDate}}</p>
        <p><strong>Period:</strong> {{periodStart}} - {{periodEnd}}</p>
        {{#if delivery.meteringPointId}}
        <p><strong>Mätpunkt:</strong> {{delivery.meteringPointId}}</p>
        <p><strong>Elområde:</strong> {{delivery.gridArea}}</p>
        <p><strong>Förbrukning:</strong> {{delivery.consumption}} {{delivery.consumptionUnit}}</p>
        {{/if}}
    </div>
    
    <table>
        <thead>
            <tr>
                <th>Beskrivning</th>
                <th>Antal</th>
                <th>Enhet</th>
                <th>Pris</th>
                <th>Belopp</th>
            </tr>
        </thead>
        <tbody>
            {{#each invoiceDetails.lineItems}}
            <tr>
                <td>{{this.description}}</td>
                <td>{{formatNumber this.quantity decimals=2}}</td>
                <td>{{this.unit}}</td>
                <td>{{formatCurrency this.unitPrice}}</td>
                <td>{{formatCurrency this.lineAmount}}</td>
            </tr>
            {{/each}}
            <tr class="total-row">
                <td colspan="4"><strong>Delsumma:</strong></td>
                <td><strong>{{formatCurrency invoiceDetails.subTotal}}</strong></td>
            </tr>
            <tr>
                <td colspan="4"><strong>Moms (25%):</strong></td>
                <td><strong>{{formatCurrency invoiceDetails.taxAmount}}</strong></td>
            </tr>
            <tr class="total-row">
                <td colspan="4"><strong>Att betala:</strong></td>
                <td><strong>{{formatCurrency invoiceDetails.totalAmount}} {{currency}}</strong></td>
            </tr>
        </tbody>
    </table>
    
    {{#if payment.paymentId}}
    <div class="payment-info">
        <h3>Betalningsinformation</h3>
        <p><strong>OCR-nummer:</strong> {{payment.paymentId}}</p>
        <p><strong>Bankgiro:</strong> {{payment.bankAccount}}</p>
        <p><strong>Förfallodatum:</strong> {{payment.dueDate}}</p>
    </div>
    {{/if}}
</body>
</html>


 

Dependencies:

  • Handlebars.Net library for template rendering
  • Playwright library for PDF generation
  • Azure Storage Queue: batch-items-queue
  • Blob storage for templates, JSON, PDF, HTML
  • Organization configuration in blob
  • Custom Handlebars helpers (formatCurrency, formatNumber, formatDate)

Risks & Mitigation:

RiskLikelihoodImpactMitigation StrategyOwner
Handlebars rendering performanceHIGHHIGH- Pre-compile templates on first use
- Cache compiled templates (24h TTL)
- Parallel rendering for 32 items
- POC: 1000 renders in <30 seconds
Technical Architect
Playwright memory consumptionHIGHHIGH- Semaphore limit: max 10 concurrent PDFs
- Worker instance memory monitoring
- Graceful degradation if memory high
- Browser instance pooling
Technical Architect
Swedish character encoding (åäö)MEDIUMMEDIUM- UTF-8 throughout entire pipeline
- Font embedding in PDF
- Visual testing with Swedish content
- Sample invoices with all Swedish special chars
QA Team
Template injection securityLOWCRITICAL- Handlebars safe mode (no eval)
- Template sanitization on upload
- No dynamic helper registration
- Security code review
Security Officer
Missing template categoryLOWMEDIUM- Fall back to default "invoice" template
- Log warning for missing category
- Template category validation
Product Owner


3.5 FR-005: Email Delivery Service

Requirement: The EmailDeliveryService shall listen to email-queue, download PDF from blob storage, send via SendGrid with Swedish-localized email template, track delivery status, and retry on transient failures with fallback to postal queue.

Priority: HIGH

Service Specifications:

Trigger: Message in email-queue
Input: Invoice ID, recipient email, organization ID
Output: Email sent via SendGrid, delivery status updated

Processing Steps:

1. Dequeue message from email-queue
2. Download PDF: {org}-invoices-{year}/{month}/{day}/{invoice-id}.pdf
3. Load organization email configuration
4. Load email template (Swedish)
5. Create SendGrid message:
   - From: noreply@{org-domain}.com
   - To: {customer-email}
   - Subject: "Faktura {invoiceNumber} från {orgName}"
   - Body: HTML template with invoice summary
   - Attachment: invoice-{invoiceNumber}.pdf
6. Send via SendGrid API
7. Handle response:
   - Success (2xx): Update invoice status="delivered", log messageId
   - Rate limit (429): Re-queue with Retry-After delay
   - Transient error (5xx): Retry with exponential backoff (3x)
   - Permanent error (4xx): Move to postal-bulk-queue (fallback)
8. Update invoice metadata with delivery attempt
9. Delete from email-queue (on success or permanent failure)


 

Email Template (Swedish):

<!DOCTYPE html>
<html lang="sv">
<head>
    <meta charset="UTF-8">
    <title>Faktura</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
    <div style="background: #0066CC; color: white; padding: 20px; text-align: center;">
        <h1>Faktura från {{organizationName}}</h1>
    </div>
    
    <div style="padding: 20px;">
        <p>Hej {{customerName}},</p>
        
        <p>Din faktura för perioden {{periodStart}} till {{periodEnd}} är nu tillgänglig.</p>
        
        <table style="width: 100%; margin: 20px 0; border-collapse: collapse;">
            <tr style="background: #f5f5f5;">
                <td style="padding: 10px; border: 1px solid #ddd;"><strong>Fakturanummer:</strong></td>
                <td style="padding: 10px; border: 1px solid #ddd;">{{invoiceNumber}}</td>
            </tr>
            <tr>
                <td style="padding: 10px; border: 1px solid #ddd;"><strong>Förfallodatum:</strong></td>
                <td style="padding: 10px; border: 1px solid #ddd;">{{dueDate}}</td>
            </tr>
            <tr style="background: #f5f5f5;">
                <td style="padding: 10px; border: 1px solid #ddd;"><strong>Att betala:</strong></td>
                <td style="padding: 10px; border: 1px solid #ddd;"><strong>{{totalAmount}} SEK</strong></td>
            </tr>
        </table>
        
        <p><strong>Betalningsinformation:</strong></p>
        <p>Bankgiro: {{bankAccount}}<br>
        OCR-nummer: {{ocrNumber}}</p>
        
        <p>Din faktura finns bifogad som PDF.</p>
        
        <p>Vid frågor, kontakta oss på {{supportEmail}} eller {{supportPhone}}.</p>
        
        <p>Med vänlig hälsning,<br>
        {{organizationName}}</p>
    </div>
    
    <div style="background: #f5f5f5; padding: 15px; text-align: center; font-size: 12px; color: #666;">
        <p>Detta är ett automatiskt meddelande. Svara inte på detta e-postmeddelande.</p>
    </div>
</body>
</html>


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Listens to email-queueSend message, verify processingQueue messageMessage dequeued
Downloads PDF from correct blob pathVerify blob access loggedInvoice with PDFPDF downloaded
Sends via SendGrid APIMock SendGrid, verify API callEmail invoiceSendGrid API called
PDF attached to emailReceive test email, check attachmentEmail invoicePDF attached
Subject includes invoice number (Swedish)Check email subjectInvoice 123"Faktura 123 från..."
From address uses org domainCheck email headersOrg configFrom: noreply@acme.com
Reply-to set to org supportCheck email headersOrg configReply-To: support@acme.com
Swedish email template usedCheck email bodyEmail invoiceSwedish text
Retry 2x on transient failure (1min, 5min)Force 500 error from SendGridFailing email2 retries logged
Fallback to postal on permanent failureForce 400 error (invalid email)Bad emailPostal queue message
Delivery status tracked in invoice metadataCheck metadata after sendDelivered invoicedeliveryAttempts array updated
SendGrid messageId loggedCheck invoice metadataDelivered invoiceproviderMessageId present
Rate limit handling (429)Simulate rate limitMany emailsRe-queued with delay
Email size validation (<25MB)Large PDF attachment30MB PDFError or compression

SendGrid Configuration:

{
  "sendgrid": {
    "apiKey": "{{from-azure-keyvault}}",
    "fromEmail": "noreply@{org-domain}.com",
    "fromName": "{organizationName}",
    "replyTo": "support@{org-domain}.com",
    "tracking": {
      "clickTracking": false,
      "openTracking": true,
      "subscriptionTracking": false
    },
    "mailSettings": {
      "sandboxMode": false,
      "spamCheck": {
        "enable": true,
        "threshold": 5
      }
    }
  }
}


 

Dependencies:

  • SendGrid account with Nordic IP reputation
  • Azure Storage Queue: email-queue
  • Email templates in Swedish (+ future: Norwegian, Danish, Finnish)
  • Organization email configuration in blob
  • Fallback queue routing to postal

Risks & Mitigation (Nordic Email Deliverability):

RiskLikelihoodImpactMitigation StrategyOwner
Swedish ISP spam filtering (Telia, Tele2, Telenor)MEDIUMHIGH- Dedicated IP warmup (2-week ramp)
- SPF: include:sendgrid.net
- DKIM signing enabled
- DMARC p=quarantine policy
- Monitor bounce rates by ISP
- Request whitelisting from major ISPs
Operations Manager
SendGrid rate limits (enterprise plan needed)MEDIUMMEDIUM- Enterprise plan: 2M emails/month
- Queue-based pacing
- Monitor daily send volume
- Distribute sends over 24 hours
- Priority queue for SLA customers
Product Owner
PDF attachment size (>25MB)LOWLOW- Compress PDFs with Ghostscript
- Target: <5MB per invoice
- Alert if PDF >20MB
- Fallback: send download link
Technical Architect
Email template rendering errorsLOWMEDIUM- Template validation on deployment
- Fallback to plain text if HTML fails
- Error monitoring
- Sample sends for all templates
QA Team
Customer email address invalidMEDIUMLOW- Email validation before send
- Skip email, go directly to postal
- Log invalid addresses for org to correct
Product Owner


3.6 FR-006: Postal Delivery Service (21G Bulk Integration)

Requirement: The PostalDeliveryService shall listen to postal-bulk-queue, collect invoices for bulk processing, create ZIP archive with PDFs and XML metadata in 21G format, upload to 21G SFTP server at scheduled times (12:00 and 20:00 Swedish time), and track delivery confirmations.

Priority: HIGH

Service Specifications:

Trigger: Scheduled execution (12:00 and 20:00 CET/CEST)
Input: All messages in postal-bulk-queue accumulated since last run
Output: ZIP file uploaded to 21G SFTP, delivery confirmations tracked

Processing Steps:

1. Scheduled trigger (12:00 and 20:00 Swedish time)
2. Fetch all messages from postal-bulk-queue (batch retrieval)
3. For each invoice in queue:
   a. Download PDF: {org}-invoices-{year}/{month}/{day}/{invoice-id}.pdf
   b. Download invoice JSON for metadata
   c. Validate recipient address is complete
   d. Add to 21G batch collection
4. Group by organization (21G requires org-specific batches)
5. For each organization batch:
   a. Create 21G XML metadata file with all invoice details
   b. Create ZIP archive: {org-code}_{date}_{sequence}.zip
      - Contains: invoice1.pdf, invoice2.pdf, ..., metadata.xml
   c. Upload ZIP to 21G SFTP: /incoming/{org-code}/
   d. Verify upload success
   e. Update all invoice statuses: status="postal_sent"
   f. Delete messages from postal-bulk-queue
6. Log bulk send statistics to Application Insights
7. Send notification email to organization (bulk send report)


 

21G ZIP Structure:

ACME_20251121_001.zip
├── metadata.xml (21G format)
├── invoice_001.pdf
├── invoice_002.pdf
├── invoice_003.pdf
└── ...


 

21G Metadata XML Format:

<?xml version="1.0" encoding="UTF-8"?>
<PrintBatch xmlns="urn:21g:print:batch:1.0">
  <BatchHeader>
    <BatchId>ACME_20251121_001</BatchId>
    <OrganizationCode>ACME</OrganizationCode>
    <CreationDate>2025-11-21T12:00:00</CreationDate>
    <TotalDocuments>150</TotalDocuments>
    <ServiceLevel>Economy</ServiceLevel>
  </BatchHeader>
  <Documents>
    <Document>
      <DocumentId>invoice_001.pdf</DocumentId>
      <DocumentType>Invoice</DocumentType>
      <Recipient>
        <Name>Medeni Schröder</Name>
        <Street>Strandbo 63B</Street>
        <PostalCode>352 58</PostalCode>
        <City>Växjö</City>
        <Country>SE</Country>
      </Recipient>
      <PrintOptions>
        <Format>A4</Format>
        <Color>false</Color>
        <Duplex>false</Duplex>
      </PrintOptions>
    </Document>
    <!-- ... more documents -->
  </Documents>
</PrintBatch>


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Scheduled execution at 12:00 Swedish timeCheck execution logsScheduled timeRuns at 12:00 CET/CEST
Scheduled execution at 20:00 Swedish timeCheck execution logsScheduled timeRuns at 20:00 CET/CEST
Fetches all messages from postal-bulk-queueQueue 100 messages, verify all fetched100 postal invoicesAll 100 fetched
Downloads PDFs from blob storageVerify blob accessPostal invoicesAll PDFs downloaded
Validates recipient address completeInvoice with missing cityIncomplete addressSkipped with error log
Groups by organizationMix of Org A and Org B invoicesMulti-org batchSeparate ZIPs per org
Creates 21G XML metadataVerify XML structurePostal batchValid 21G XML
Creates ZIP archiveVerify ZIP contentsPostal batchPDFs + metadata.xml
Uploads to 21G SFTPMock SFTP, verify uploadZIP fileFile uploaded
Verifies upload successCheck SFTP confirmationUploaded ZIPConfirmation received
Updates invoice status to "postal_sent"Check invoice metadataSent invoicesStatus updated
Deletes messages from queueCheck queue after processingProcessed batchQueue empty
Logs bulk statisticsCheck Application InsightsProcessed batchStatistics logged
Sends org notification emailCheck email receivedProcessed batchEmail with counts
Handles SFTP connection errorsSimulate SFTP downPostal batchRetry logged, alert sent
Respects 21G batch size limitsCreate large batch10,000 invoicesSplit into multiple ZIPs

21G Integration Specifications:

SFTP Connection:

  • Host: sftp.21g.se (or provider-specific)
  • Port: 22
  • Authentication: SSH key (stored in Azure Key Vault)
  • Directory structure: /incoming/{org-code}/
  • File naming: {org-code}{YYYYMMDD}{sequence}.zip

21G SLA:

  • Processing time: 24-48 hours
  • Confirmation: Email notification when processed
  • Tracking: Available via 21G portal

Dependencies:

  • 21G SFTP account and credentials
  • Azure Storage Queue: postal-bulk-queue
  • Scheduled worker (Azure Container Apps with CRON)
  • ZIP file creation library
  • 21G XML schema compliance
  • Email notification service

Risks & Mitigation (Nordic Postal Context):

RiskLikelihoodImpactMitigation StrategyOwner
21G SFTP connectivity issuesLOWHIGH- Retry logic (3 attempts with 5min delay)
- Secondary SFTP credentials
- Alert on connection failure
- Manual upload procedure documented
- 21G support contact documented
Operations Manager
Swedish postal delays (holidays, strikes)MEDIUMMEDIUM- Set customer expectations (5-7 days)
- Monitor 21G processing SLA
- Track delivery confirmations
- Escalation for >10 days
- Alternative print partner identified
Product Owner
Incomplete recipient addressesMEDIUMLOW- Address validation before queueing
- Skip invalid addresses
- Alert organization of invalid addresses
- Provide address correction interface
Product Owner
21G format specification changesLOWMEDIUM- Version 21G XML schema
- Monitor 21G API announcements
- Test uploads to 21G staging
- 21G account manager liaison
Technical Architect
ZIP file corruptionLOWHIGH- SHA-256 checksum in metadata
- Verify ZIP integrity before upload
- Keep ZIP in blob for 30 days
- 21G confirms successful unzip
Technical Architect


3.7 FR-007: Distribution Routing Logic

Requirement: The system shall determine the appropriate distribution queue for each invoice based on organization configuration, customer preferences, and distribution type (invoice vs document) following Swedish regulatory requirements.

Priority: HIGH

Routing Decision Tree:

Invoice Distribution Routing
│
├─ Check customer preference (if available)
│  ├─ Preference = "digital" → email-queue (or kivra-queue in future)
│  └─ Preference = "postal" → postal-bulk-queue
│
├─ Check organization default channels (from config)
│  ├─ Priority 1: email
│  │  └─ Has valid email? → email-queue
│  ├─ Priority 2: kivra (Phase 2)
│  │  └─ Kivra user? → kivra-queue
│  └─ Priority 3: postal
│     └─ postal-bulk-queue
│
├─ Document type consideration
│  ├─ Invoice (faktura) → All channels available
│  └─ Confirmation letter → Email/postal only
│
└─ Swedish regulatory compliance
   └─ Customer always has right to postal ("rätt till pappersfaktura")


 

Queue Selection Logic:

public async Task<string> DetermineDistributionQueueAsync(
    InvoiceDistribution distribution,
    OrganizationConfig config)
{
    // Customer preference overrides (if explicitly set)
    if (distribution.CustomerPreference == "postal")
        return "postal-bulk-queue";
    
    // Try channels in priority order
    var priorities = config.DeliveryChannels.ChannelPriority
        .OrderBy(p => p.Priority);
    
    foreach (var channel in priorities)
    {
        switch (channel.Channel)
        {
            case "email":
                if (!string.IsNullOrEmpty(distribution.CustomerEmail) &&
                    IsValidEmail(distribution.CustomerEmail))
                {
                    return "email-queue";
                }
                break;
                
            case "kivra": // Phase 2
                if (await IsKivraUserAsync(distribution.CustomerPersonnummer))
                {
                    return "kivra-queue";
                }
                break;
                
            case "efaktura": // Phase 2 (B2B only)
                if (distribution.CustomerType == "business" &&
                    !string.IsNullOrEmpty(distribution.OrganizationNumber))
                {
                    return "efaktura-queue";
                }
                break;
                
            case "postal":
                if (distribution.IsCompleteAddress())
                {
                    return "postal-bulk-queue";
                }
                break;
        }
    }
    
    // Ultimate fallback: postal (Swedish law requires paper option)
    return "postal-bulk-queue";
}


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Customer preference honoredSet preference="postal"Email-enabled invoiceRoutes to postal queue
Organization priority followedPriority: [email, postal]Valid emailRoutes to email queue
Email validated before routingInvalid email addressbad-email@invalidRoutes to postal queue
Complete address required for postalMissing postal codeIncomplete addressError logged, skipped
Document type consideredConfirmation letterNon-invoice docOnly email/postal
Swedish postal fallbackAll digital channels failFailed digitalpostal-bulk-queue
Business invoices support e-faktura (future)Organization number presentB2B invoiceefaktura-queue (Phase 2)
Routing decision loggedCheck logsAny invoiceDecision reason logged

Dependencies:

  • Organization delivery configuration
  • Customer preference storage (future)
  • Email validation library
  • Address validation library (Swedish postal codes)
  • Kivra user lookup API (Phase 2)

Risks & Mitigation:

RiskLikelihoodImpactMitigation StrategyOwner
Invalid email addresses (>5% in Nordic utilities)HIGHLOW- Email validation regex
- Automatic postal fallback
- Report invalid emails to organization
- Customer data quality improvement program
Product Owner
Incomplete postal addressesMEDIUMMEDIUM- Address validation against Swedish postal database
- Skip invalid addresses with alert
- Organization notification of incomplete addresses
Product Owner
Swedish "rätt till pappersfaktura" complianceLOWCRITICAL- Always enable postal as fallback
- Never force digital-only
- Document compliance in privacy policy
- Legal review of routing logic
Legal/Compliance


3.8 FR-008: Blob Concurrency Control (Note: Read-Only, No Concurrent Updates)

Requirement: The system shall use blob leases to ensure exclusive access during batch processing, preventing concurrent worker instances from processing the same 32-item batch.

Priority: HIGH

Clarification: Based on your feedback, there are no concurrent updates to files - workers only read invoice JSON files. The blob lease is used to ensure only one worker processes a given 32-item batch from the queue.

Lease Implementation:

public async Task ProcessBatchItemsAsync(BatchItemsMessage message)
{
    var lockBlobPath = $"{message.OrganizationId}-batches-{year}/{month}/{day}/{message.BatchId}/locks/{message.MessageId}.lock";
    BlobLease lease = null;
    
    try
    {
        // Acquire lease (5-minute duration)
        lease = await _blobLockService.AcquireLockAsync(
            containerName: $"{message.OrganizationId}-batches-{year}",
            blobName: lockBlobPath,
            leaseDuration: TimeSpan.FromMinutes(5));
        
        // Process 32 invoices (read-only operations)
        foreach (var invoiceId in message.InvoiceIds)
        {
            // Read invoice JSON from blob (no updates to JSON)
            var invoiceJson = await _blobStorage.DownloadJsonAsync(invoiceId);
            
            // Render and generate (creates new HTML/PDF blobs)
            await RenderAndGenerateAsync(invoiceJson);
            
            // No concurrent update risk - creating new blobs only
        }
        
        // Update batch metadata (ETag-based optimistic concurrency)
        await UpdateBatchMetadataAsync(message.BatchId, meta =>
        {
            meta.Statistics.ProcessedItems += message.InvoiceIds.Count;
        });
    }
    finally
    {
        if (lease != null)
        {
            await _blobLockService.ReleaseLockAsync(lease);
        }
    }
}


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Acquires blob lease before processingStart processing, check lease32-item batchLease acquired
Lease duration is 5 minutesCheck lease propertiesAny batchDuration = 5 min
Only one worker processes batchSend same message to 2 workersDuplicate messageOne succeeds, one waits
Lease renewed for long processingProcess 32 items slowlySlow batchLease renewed
Lease released on completionCheck lease after processingCompleted batchLease released
Lease released on errorForce error during processingFailing batchLease released
Different batches process in parallelQueue 10 batches10 x 32 itemsAll process concurrently
Batch metadata updates use ETagsConcurrent metadata updates2 workers update statsNo lost updates

Dependencies:

  • Azure Blob Storage lease API
  • ETag-based optimistic concurrency for metadata updates
  • Retry logic for lease acquisition conflicts


3.9 FR-009: Queue Message Handling

Requirement: The system shall process queue messages with proper visibility timeouts, automatic retry on failure, poison queue handling, and message deduplication.

Priority: HIGH

Queue Configuration:

Queue NamePurposeVisibility TimeoutMax Delivery CountDead Letter Queue
batch-upload-queueTriggers ParserService10 minutes3poison-queue
batch-items-queueTriggers DocumentGenerator (32 items)5 minutes3poison-queue
email-queueTriggers EmailService2 minutes3poison-queue
postal-bulk-queueCollected for 21G bulk sendN/A (batch retrieval)1poison-queue
poison-queueFailed messages for manual reviewN/A0None

Message Format Standard:

{
  "messageId": "uuid",
  "messageType": "batch.upload|batch.items|email.delivery|postal.delivery",
  "version": "1.0",
  "timestamp": "2025-11-21T10:30:00Z",
  "data": {
    // Message-specific payload
  },
  "metadata": {
    "correlationId": "uuid",
    "organizationId": "uuid",
    "retryCount": 0,
    "enqueuedAt": "2025-11-21T10:30:00Z"
  }
}


 

Retry Policy (Exponential Backoff):

AttemptDelayTotal Elapsed
1Immediate0
260 seconds1 minute
3300 seconds6 minutes
4900 seconds21 minutes
FailedPoison queue-

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Messages have proper visibility timeoutCheck queue propertiesAny messageCorrect timeout set
Failed messages retry automaticallyForce error, verify retryFailing message3 retries attempted
Retry count incrementedCheck message metadataRetried messageretryCount incremented
Exponential backoff appliedMeasure retry delaysFailing message1min, 5min, 15min
After 3 retries, moved to poison queueForce permanent failureFailing messageIn poison queue
Poison queue triggers alertMessage in poison queueFailed messageAlert email sent
Support team notifiedCheck alert recipientsPoison messageSupport receives email
No duplicate processing (idempotent)Send duplicate messageSame invoice IDProcessed once
Correlation ID traces through systemFollow message across queuesAny messageSame correlationId

Poison Queue Handling:

{
  "messageId": "uuid",
  "originalMessageType": "batch.items",
  "failedAt": "2025-11-21T10:50:00Z",
  "retryCount": 3,
  "lastError": {
    "code": "TEMPLATE_RENDERING_FAILED",
    "message": "Required variable 'customer.address.street' not found in template",
    "stackTrace": "...",
    "attemptTimestamps": [
      "2025-11-21T10:35:00Z",
      "2025-11-21T10:36:00Z",
      "2025-11-21T10:41:00Z",
      "2025-11-21T10:56:00Z"
    ]
  },
  "originalMessage": {
    // Full original message for debugging
  },
  "metadata": {
    "correlationId": "uuid",
    "alertSent": true,
    "alertRecipients": ["support@egflow.com"],
    "manualReviewRequired": true
  }
}


 

Dependencies:

  • Azure Storage Queues with dead-letter queue support
  • Alert service for poison queue notifications
  • Monitoring dashboard for poison queue depth


3.10 FR-010: Health Check Endpoints

Requirement: All services shall expose health check endpoints that verify connectivity to dependencies (database, blob storage, queues) and return health status for Azure Traffic Manager and monitoring.

Priority: HIGH

API Endpoint: GET /health

Response Format (Healthy):

{
  "status": "Healthy",
  "timestamp": "2025-11-21T10:30:00Z",
  "version": "1.0.0",
  "checks": {
    "blobStorage": {
      "status": "Healthy",
      "responseTime": "45ms",
      "lastChecked": "2025-11-21T10:30:00Z"
    },
    "storageQueue": {
      "status": "Healthy",
      "responseTime": "32ms",
      "queueDepth": 150
    },
    "postgresql": {
      "status": "Healthy",
      "responseTime": "12ms",
      "activeConnections": 8
    },
    "keyVault": {
      "status": "Healthy",
      "responseTime": "67ms"
    }
  },
  "environment": "production",
  "region": "westeurope"
}


 

Response Format (Unhealthy):

{
  "status": "Unhealthy",
  "timestamp": "2025-11-21T10:30:00Z",
  "checks": {
    "blobStorage": {
      "status": "Unhealthy",
      "error": "Connection timeout after 5000ms",
      "lastChecked": "2025-11-21T10:30:00Z"
    },
    "postgresql": {
      "status": "Healthy",
      "responseTime": "15ms"
    }
  }
}


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Returns 200 when all checks healthyAll dependencies upN/A200 OK, status="Healthy"
Returns 503 when any check unhealthyStop databaseN/A503 Service Unavailable
Checks blob storage connectivityDisconnect blob storageN/AblobStorage.status="Unhealthy"
Checks queue connectivityDisable queue accessN/AstorageQueue.status="Unhealthy"
Checks PostgreSQL connectivityStop databaseN/Apostgresql.status="Unhealthy"
Checks Key Vault accessRevoke Key Vault permissionsN/AkeyVault.status="Unhealthy"
Response time < 1 secondPerformance testN/AHealth check completes quickly
Traffic Manager uses for routingSimulate region failureN/ATraffic routes to healthy region
Includes environment and regionCheck response bodyN/AEnvironment and region present

Dependencies:

  • Health check library (ASP.NET Core HealthChecks)
  • Azure Traffic Manager configuration
  • Monitoring integration


3.11 FR-011: Template Category Management

Requirement: The system shall support template categories (e.g., "Invoice", "Confirmation Letter", "Reminder") to group related templates and enable dynamic template selection based on document type.

Priority: MEDIUM

API Endpoint: GET /organizations/{organizationId}/template-categories

Response Format:

{
  "success": true,
  "data": {
    "categories": [
      {
        "categoryId": "uuid",
        "categoryName": "invoice",
        "displayName": "Faktura",
        "description": "Standard invoice template",
        "activeTemplateId": "uuid",
        "activeTemplateVersion": "2.1.0",
        "templateCount": 3
      },
      {
        "categoryId": "uuid",
        "categoryName": "confirmation",
        "displayName": "Bekräftelsebrev",
        "description": "Contract confirmation letter",
        "activeTemplateId": "uuid",
        "activeTemplateVersion": "1.0.0",
        "templateCount": 1
      },
      {
        "categoryId": "uuid",
        "categoryName": "reminder",
        "displayName": "Påminnelse",
        "description": "Payment reminder",
        "activeTemplateId": null,
        "templateCount": 0
      }
    ]
  }
}


 

Template Category Determination Logic:

Document Type → Template Category Mapping:

Invoice (faktura) → "invoice" template
Confirmation letter (bekräftelsebrev) → "confirmation" template
Payment reminder (påminnelse) → "reminder" template
Termination notice (uppsägning) → "termination" template
Contract change (avtalsändring) → "contract_change" template

Default: If category not found → use "invoice" template


 

Acceptance Criteria:

CriterionValidation MethodTest DataExpected Result
Lists all categories for organizationGET /template-categoriesOrg with 3 categories3 categories returned
Shows active template per categoryCheck activeTemplateIdCategory with active templateTemplate ID present
Returns null for unused categoriesCheck reminder categoryNo reminder templateactiveTemplateId: null
Includes template countVerify countCategory with 3 versionstemplateCount: 3
Category names localized (Swedish)Check displayNameAll categoriesSwedish names


3.12 FR-012: Git Branching Strategy (Development Workflow)

Requirement: The development team shall follow a structured Git branching strategy with development, staging, and main branches, following industry best practices for continuous integration and deployment.

Priority: HIGH

Branch Structure (Updated per Nov 20 decision):

main (production)
  ↑
  └── staging (acceptance testing)
       ↑
       └── development (integration testing)
            ↑
            └── feature/* (short-lived branches)


 

Branch Policies:

BranchPurposeMerge FromDeploy ToProtection
feature/Individual featuresN/AN/ANone (local only)
developmentIntegration testingfeature/*Dev environmentPR required, 1 approval
stagingAcceptance testing (internal)developmentStaging environmentPR required, 2 approvals, all tests pass
mainProductionstagingProduction (West + North Europe)PR required, 3 approvals, security scan, all tests pass

Deployment Flow:

Developer → feature/GAS-12345-batch-upload
   ↓
   PR to development
   ↓
CI/CD: Build, Test, Deploy to Dev
   ↓
Integration testing in Dev
   ↓
   PR to staging (approved by Product Owner)
   ↓
CI/CD: Build, Test, Deploy to Staging
   ↓
UAT testing in Staging (European team only)
   ↓
   PR to main (approved by Product Owner + Architect + Ops)
   ↓
CI/CD: Build, Security Scan, Deploy to Prod (West Europe)
   ↓
Deploy to Prod (North Europe) - manual approval


 

Acceptance Criteria:

CriterionValidation MethodExpected Result
Feature branches merge to development onlyAttempt direct merge to stagingPR blocked
Development branch auto-deploys to dev envMerge to developmentDev environment updated
Staging branch requires 2 approvalsCreate PR to stagingCannot merge with 1 approval
Main branch requires 3 approvalsCreate PR to mainCannot merge with 2 approvals
All tests must pass before staging mergeFailing test in PRMerge blocked
Security scan required for mainPR to mainSAST scan runs
Feature branches deleted after mergeMerge feature branchBranch auto-deleted

Dependencies:

  • Azure DevOps or GitHub repository
  • CI/CD pipeline configuration
  • Branch protection policies
  • Code review requirements


3.13 FR-013: Synthetic Test Data Generation (GDPR Compliance)

Requirement: The system shall provide a synthetic data generation tool that creates realistic but fake Swedish invoice data (personnummer, addresses, names) for use in staging and development environments, with zero production data copying.

Priority: CRITICAL

Synthetic Data Requirements:

Swedish Personnummer Generation:

Format: YYMMDD-XXXX
- YY: Year (00-99)
- MM: Month (01-12)
- DD: Day (01-31)
- XXXX: Last 4 digits with Luhn checksum

Generation Rules:
- Must pass Luhn algorithm validation
- Must NOT match any real personnummer
- Use test ranges: 19000101-19991231 (obviously fake dates)
- Flag as test: personnummer starts with "19"


 

Swedish Address Generation:

Street names: Random from Swedish street database
- Storgatan, Kungsgatan, Vasagatan, Drottninggatan...
- House numbers: 1-150
- Apartment: A-Z (optional)

Cities: Top 50 Swedish cities
- Stockholm, Göteborg, Malmö, Uppsala, Västerås...

Postal codes: Valid format (XXX XX) but non-existent ranges
- Use: 00X XX range (invalid but correct format)

Examples:
- Storgatan 45, 001 23 Stockholm
- Vasagatan 12 A, 002 45 Göteborg


 

Synthetic Invoice Data:

Invoice numbers: Test prefix "TEST-" + sequential
Amounts: Random between 100-5000 SEK
Consumption: Random 100-2000 kWh (residential realistic)
Metering points: Test range 735999999999999XXX
Email addresses: {firstname}.{lastname}@example-test.se
Phone numbers: +46701234XXX (test range)


 

Acceptance Criteria:

CriterionValidation MethodExpected Result
Generates valid personnummer (Luhn check)Validate 1000 generated numbersAll pass Luhn validation
Personnummer obviously fakeReview generated numbersAll start with "19" (invalid birth years)
Addresses realistic but invalidCheck against real postal databaseNo matches found
Email addresses use test domainCheck generated emailsAll @example-test.se
Phone numbers in test rangeCheck generated phonesAll +467012340XX
Can generate 10K invoice batchGenerate full batch10K valid invoices
Zero real data in outputScan for real personnummer patternsNo real data found
Reproducible (seed-based)Generate twice with same seedIdentical output

Dependencies:

  • Swedish personnummer validation library
  • Swedish postal code database (for validation, not generation)
  • Random data generation library

Risks & Mitigation:

RiskLikelihoodImpactMitigation StrategyOwner
Accidental real data generationLOWCRITICAL- Validation against known real ranges
- Visual "TEST DATA" watermark on PDFs
- Automated scanning for real personnummer
- Code review of generation logic
Security Officer
Unrealistic test scenariosMEDIUMMEDIUM- Generate edge cases library
- Long names, special characters
- Missing optional fields
- Various consumption patterns
QA Team
Offshore team needs production debuggingMEDIUMMEDIUM- European team creates synthetic scenarios
- Screen sharing for production issues
- Never copy production data
- Comprehensive logs without PII
Operations Manager



  • No labels