Compare commits

..
6 Commits
Author SHA1 Message Date
forge-recovery dba6990929 Merge pull request 'ci: add the QRun release caller' (#42) from ci-qrun-release-caller-main-v1 into main
QRun-IO/ci-policy: ci / fork-guard (push) Skipped
QRun-IO/ci-policy: ci / head-profile (push) Skipped
QRun-IO/ci-policy: ci / gradle-1 (push) Skipped
QRun-IO/ci-policy: ci / application-template-0 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (push) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (push) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (push) Skipped
QRun-IO/ci-policy: ci / node-8 (push) Skipped
QRun-IO/ci-policy: ci / node-9 (push) Skipped
QRun-IO/ci-policy: ci / node-10 (push) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (push) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (push) Skipped
QRun-IO/ci-policy: ci / source-config-13 (push) Skipped
QRun-IO/ci-policy: ci / source-config-14 (push) Skipped
QRun-IO/ci-policy: ci / not-enrolled (push) Skipped
QRun-IO/ci-policy: native / fork-deny (push) Skipped
QRun-IO/ci-policy: native / dotnet-0 (push) Skipped
Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: native / native-result (push) Successful in 4s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (push) Successful in 17s
QRun-IO/ci-policy: ci / repository-policy (push) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (push) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (push) Skipped
QRun-IO/ci-policy: ci / java-maven-7-head (push) Skipped
Shared Java Maven CI / verify (push) Successful in 54s
Shared Java Maven CI / gate (push) Successful in 10s
qrun-release / release (push) Successful in 57m2s
qrun-release / postpublish (push) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / verify (push) Successful in 49s
QRun-IO/ci-policy: Shared Java Maven CI / gate (push) Successful in 9s
QRun-IO/ci-policy: ci / java-maven-6 (push) Successful in 57m49s
QRun-IO/ci-policy: ci / ci-result (push) Successful in 5s
2026-11-02 18:44:12 +00:00
24a1896519 ci: add the isolated postpublish job to the QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / head-profile (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-4 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-10 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-11 (pull_request) Skipped
QRun-IO/ci-policy: ci / repository-policy-12 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-13 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-14 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
Shared Java Maven CI / secrets (push) Successful in 18s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 16s
QRun-IO/ci-policy: ci / repository-policy (pull_request) Skipped
QRun-IO/ci-policy: ci / java-core-2-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-3-head (pull_request) Skipped
QRun-IO/ci-policy: ci / java-maven-6-head (pull_request) Skipped
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: ci / java-maven-7-head (pull_request) Skipped
Shared Java Maven CI / gate (push) Canceled after 0s
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 2h37m57s
qrun-release / postpublish (push) Canceled after 0s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Successful in 54s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Successful in 8s
QRun-IO/ci-policy: ci / java-maven-6 (pull_request) Successful in 2h53m20s
QRun-IO/ci-policy: ci / ci-result (pull_request) Successful in 4s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 15:49:11 +00:00
bc770fc71c ci: move the QRun release caller to the thin v2 contract
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-9 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: native / fork-deny (pull_request) Skipped
QRun-IO/ci-policy: native / dotnet-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Shared Java Maven CI / secrets (push) Successful in 25s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1m12s
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 20s
QRun-IO/ci-policy: native / native-result (pull_request) Successful in 6s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Canceled after 0s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Canceled after 1h5m34s
Shared Java Maven CI / gate (push) Canceled after 0s
Shared Java Maven CI / verify (push) Canceled after 0s
qrun-release / release (push) Canceled after 1h6m1s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 06:18:04 +00:00
forge-recoveryandCodex be364eb871 ci: add the identical QRun release caller
QRun-IO/ci-policy: ci / fork-guard (pull_request) Skipped
QRun-IO/ci-policy: ci / application-template-0 (pull_request) Skipped
QRun-IO/ci-policy: ci / gradle-1 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-docker-2 (pull_request) Skipped
QRun-IO/ci-policy: ci / java-frontend-browser-3 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-5 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-6 (pull_request) Skipped
QRun-IO/ci-policy: ci / node-playwright-7 (pull_request) Skipped
QRun-IO/ci-policy: ci / source-config-8 (pull_request) Skipped
QRun-IO/ci-policy: ci / not-enrolled (pull_request) Skipped
QRun-IO/ci-policy: Shared Java Maven CI / secrets (pull_request) Successful in 23s
QRun-IO/ci-policy: Shared Java Maven CI / verify (pull_request) Failing after 1m6s
QRun-IO/ci-policy: Shared Java Maven CI / gate (pull_request) Failing after 0s
QRun-IO/ci-policy: ci / java-maven-4 (pull_request) Failing after 11m4s
QRun-IO/ci-policy: ci / ci-result (pull_request) Failing after 4s
Co-Authored-By: Codex <noreply@openai.com>
2026-11-02 04:10:50 +00:00
james.maesandCodex 344087dd43 ci: enroll in the shared QQQ orb (#38)
Co-authored-by: Codex <noreply@openai.com>
2026-10-21 19:51:58 -05:00
james.maesandCodex 02f3055c94 ci: use the shared orb pipeline on main (#32)
* ci: use the shared orb pipeline on main

Co-Authored-By: Codex <noreply@openai.com>

* ci: use the shared branch-aware CodeQL worker

Co-Authored-By: Codex <noreply@openai.com>

---------

Co-authored-by: Codex <noreply@openai.com>
2026-10-21 10:20:38 -05:00
236 changed files with 1135 additions and 54443 deletions

No files matched your search

+11 -57
View File
@@ -1,60 +1,14 @@
version: 2.1
setup: true
orbs:
qqq-orb: kingsrook/qqq-orb@0.6.8
commands:
configure-package-install:
steps:
- run:
name: Keep package installation unattended
command: |
# These build machines are discarded after the job.
sudo install -d /etc/needrestart/conf.d
printf '%s\n' '$nrconf{restart} = "l";' | sudo tee /etc/needrestart/conf.d/99-qqq-ci.conf >/dev/null
qqq-orb: kingsrook/qqq-orb@0.8
workflows:
test_only:
jobs:
- qqq-orb/mvn_test_only:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
filters:
branches:
ignore: /(develop|release\/.*)/
publish_snapshot:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: snapshot
filters:
branches:
only: [develop]
publish_release_candidate:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release_candidate
filters:
branches:
only: [/release\/.*/]
publish_release:
jobs:
- qqq-orb/mvn_publish:
pre-steps:
- configure-package-install
context: [qqq-maven-registry-credentials]
branch_type: release
filters:
branches:
ignore: /.*/
tags:
only: [/^v\d+\.\d+\.\d+$/]
setup:
jobs:
- qqq-orb/generate_pipeline:
orb_version: '0.8'
filters:
branches:
ignore: automation/qbit-rc-pending
tags:
ignore: /.*/
-20
View File
@@ -1,20 +0,0 @@
# QRun-IO qbit .editorconfig: the Kingsrook layout (3-space indentation, never tabs).
root = true
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
indent_size = 3
tab_width = 3
[*.md]
trim_trailing_whitespace = false
[*.{yml,yaml}]
indent_size = 2
[*.{cmd,bat}]
end_of_line = crlf
-3
View File
@@ -1,3 +0,0 @@
* text=auto eol=lf
*.cmd text eol=crlf
mvnw text eol=lf
+24
View File
@@ -0,0 +1,24 @@
# Standard QRun release caller (v2, thin contract): every push and manual dispatch delegates to the protected qrun-release-v1 channel. Do not edit per repository.
name: qrun-release
on:
push: {}
workflow_dispatch: {}
permissions:
contents: read
actions: read
jobs:
release:
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/java-maven.yaml@qrun-release-v1
with:
runner: linux-small
browsers: true
browser-runner: browser-build
postpublish:
if: ${{ needs.release.outputs.capture == 'true' }}
needs: release
permissions:
contents: write
actions: write
uses: self:KofTwentyTwo/platform-ci/.gitea/workflows/qrun-source-version.yaml@qrun-release-v1
with:
runner: linux-small
-25
View File
@@ -1,25 +0,0 @@
# Weekly, grouped dependency updates. Minor and patch updates arrive as one PR per ecosystem;
# major updates stay separate so each gets its own review. At most three version-update PRs per
# ecosystem are open at once. Security updates are enabled in the repository settings and are
# not limited by this schedule.
version: 2
updates:
- package-ecosystem: maven
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
maven-minor-and-patch:
update-types: [minor, patch]
- package-ecosystem: github-actions
directory: /
target-branch: develop
schedule:
interval: weekly
open-pull-requests-limit: 3
groups:
github-actions:
patterns: ['*']
+23
View File
@@ -0,0 +1,23 @@
name: CodeQL
'on':
push:
branches:
- main
- develop
- release/**
- hotfix/**
pull_request: {}
schedule:
- cron: 41 16 * * 0
workflow_dispatch: {}
permissions:
contents: read
actions: read
security-events: write
packages: read
concurrency:
group: codeql-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
analyze:
uses: QRun-IO/qqq-orb/.github/workflows/qbit-codeql.yml@c288a8864fe9054f68410e1d78f5863aea4c4d22
-25
View File
@@ -1,25 +0,0 @@
# Cut the next release candidate (QRun-IO/qqq docs/QRUN-QBIT-REPO-STANDARD.md, C6):
# run this workflow by hand, or merge a develop PR labelled `release:rc`. It merges develop into
# the release line and the release/* push runs CircleCI publish_release_candidate.
name: Release candidate
on:
workflow_dispatch:
inputs:
dry_run:
description: Print the plan without merging or creating branches
type: boolean
default: false
pull_request:
types: [closed]
branches: [develop]
permissions:
contents: write
jobs:
release-rc:
if: github.event_name == 'workflow_dispatch' || (github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release:rc'))
uses: QRun-IO/.github/.github/workflows/qbit-release-rc.yml@main
with:
dry_run: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run }}
+25 -15
View File
@@ -1,21 +1,31 @@
# Maven
target/
.env
.idea
pom.xml.tag
pom.xml.releaseBackup
pom.xml.versionsBackup
pom.xml.next
release.properties
# IDE
.idea/
*.iml
.project
.classpath
.settings/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Build
*.class
*.log
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar
hs_err_pid*
.DS_Store
*.sw*
.flattened-pom.xml
dependency-reduced-pom.xml
/.env.local
/.cache/
.claude/settings.local.json
# Logs
*.log
logs/
-3
View File
@@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip
+15
View File
@@ -0,0 +1,15 @@
profile: qbit
orb_version: '0.8'
default_branch: develop
java_version: '21'
browsers: true
api_checks: false
docs_only: false
release_on_main: false
release_tag_policy: none
test_branch_policy: all
publish:
snapshot: false
release_candidate: false
release: false
asciidoc: false
-62
View File
@@ -1,62 +0,0 @@
# AGENTS.md
This file provides guidance to Codex when working with code in this repository.
## Project Overview
**QBit EDI**: a full EDI application qbit for QQQ. Trading partners and agreements, pluggable
transports (AS2, SFTP/FTPS, VAN, HTTPS, email), X12 (004010, 005010; EDIFACT later) parsing and
validation against data-driven implementation guides, 997/999/TA1, versioned maps to QQQ records,
inbound and outbound flows, and HIPAA documents with PHI classification. Design: `docs/DESIGN.md`.
## Build Commands
```bash
./mvnw compile
./mvnw test
./mvnw verify # Checkstyle, tests, JaCoCo: 70% instructions, 90% classes
```
Always use the wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
## Architecture
Entry point: `EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>` in
`com.kingsrook.qbits.edi`. Component producers are scanned only in `com.kingsrook.qbits.edi.metadata`.
The package layout per phase is in `docs/DESIGN.md`.
## Invariants
- Data lives in standard visible QQQ tables with record screens, secured by permissions and record locks.
- Processes are table or record actions with plain labels, never in the nav; record actions use the
standard streamed-ETL Review/Result summary.
- One app home that owns the EDI data, built from typed widgets (no `RawHTML`, guardrail G5).
- Inbound transport endpoints are non-UI routes under `/qqq/hooks/edi/...`, never under `/qqq/v1`.
UI endpoints are spec'd `MiddlewareSpecExtension`s under `/qqq/v1/qbit/edi/...` with a committed fragment.
- Records are created and updated through normal QQQ actions as a service account.
- Raw documents and PHI fields are classified (`DataClassification`), stored encrypted.
- Secrets are referenced by environment-variable name only. No real PHI in tests or samples.
- No dependency on another qbit's internals (guardrail G1); notifications go through core `QAlerts`.
## Naming
- Tables: `edi_<name>`, lowercase snake_case; SQL names equal QQQ table names.
- Permissions: full name, `edi.*`.
## Code Style
- Copy the Apache header from `checkstyle/license.txt`; 3-space indent, Allman braces, `return (x);`.
- Flower-box Javadoc on every class and method; fluent `withX()` setters.
- Wrapper types in config and entities; JUnit 5 and AssertJ tests.
## Dependencies
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
## Release
CircleCI qqq-orb 0.6.8: `develop` publishes SNAPSHOTs, `release/*` publishes RCs (orb version
bumps; cut by the `release-rc.yml` workflow), `vX.Y.Z` tags publish releases. No manual publishing.
Branches are `feature/*` or `fix/*` only; never push `hotfix/*` or `release/*`.
-43
View File
@@ -1,43 +0,0 @@
# Changelog
Following [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/).
## [Unreleased]
### Added
- Phase d warehouse and logistics baseline guides for 940, 945, 214 and 204 in 004010 and 005010 (syntax notes, situational control-total and stop rules, AT7 pairing), inbound/outbound sample maps (940 in, 945 out/in, 214 in, 204 in/out), DATEPART/TIMEPART outbound transforms and `+`-joined inbound paths for date/time pairs, and Harbor Logistics and Ridgeline Freight demo traffic.
||||||| parent of bfb63ce (feat: phase c - AS2 transport)
- Phase c AS2: `As2Transport` send and receive (RFC 4130) with S/MIME signing (SHA-256/384/512; SHA1 flagged), encryption (AES-128/256 CBC or GCM; 3DES flagged), optional compression, synchronous and asynchronous signed MDNs with MIC verification, Test Connection, optional TLS pinning and VAN transports with no partner. Built on as2-lib 5.1.8 and BouncyCastle, entirely in memory (no keystore or temporary files).
- `POST /qqq/hooks/edi/as2/{transport}` and `/{transport}/mdn` route provider: identity, decryption (ACTIVE or NEXT keys), signature and MIC checks, Message-ID dedupe returning the original MDN, error MDNs with no inbox row, and a queued, retried asynchronous MDN sender.
- Read-only `edi_as2_mdn` table with "AS2 MDNs" child lists on outbound and inbound messages; raw MDNs in the encrypted payload store; `edi_outbound_message.transport_mic`; `edi_transport.weak_crypto`; `EdiQBitConfig.publicBaseUrl`.
- Certificates: parsed on save (private keys refused), keys from environment PEM PKCS#8 or base64 PKCS#12, ACTIVE/NEXT rollover, record actions Promote Certificate and Export Public Certificate, expiry alerts for ACTIVE and NEXT, and the `edi.mdn.overdue` alert.
- Demo: Pacific Wholesale over AS2 against an in-process BouncyCastle peer with in-memory certificates; AS2 acceptance tests in both directions against our own route and an independent peer.
- Phase b retail baseline guides for 855/856/810/846/860 in 004010 and 005010; inbound/outbound sample maps and nested outbound associations with SEQUENCE/HL transforms.
- Transactional RECORD_CHANGE and manual enqueue, host-wired ESB process, batch schedules, validated encrypted envelopes, send workers, retries and dead-letter alerts.
- Instance transport registry, MINA SFTP streaming push/poll with pinned host keys, gateway OUTBOX stand-in/download delivery, and authenticated HTTPS ingress/egress using core Standard Webhooks.
- Ack and certificate monitoring, functional-ack exception resolution, resend/re-envelope/cancel actions, transport tests and outbound queue home statistics.
- Test-scope H2/Next UI demo with synthetic retail traffic, in-memory gateway and public self-signed certificates; local HTTP/SFTP and application acceptance tests.
- Phase a2 inbound vertical slice: gateway intake, fenced inbox claims/retries, dead letters and core alerts; routing, validation, duplicates, safe events and exception occurrence counts.
- Versioned inbound maps, wildcard associations, transforms, permission-enforced transactional upsert and original/current revision replay.
- Queued enveloped TA1/997/999 and inbound acknowledgement reconciliation; outbound request/message tables for phase b.
- Public-API-only encrypted object-storage adapter and marked file-gateway contract stand-in.
- EDI app, typed home with permission-scoped counts, main-table search metadata, optional ESB events with identifier-only document payloads, and standard Review/Result actions.
- Synthetic sample upload, preview comparisons and encrypted JSON downloads; guide overrides and guide/map activation.
- Contingent possible values on existing/new tables, including actual filtered-search tests.
- PostgreSQL schema additions and UTC zone-less timestamps matching RC.5 RDBMS bindings.
- Phase a1 data model: 21 annotated entities, QQQ tables, enum/table value sources, joins, child lists, audit, permissions, partner locks, and PostgreSQL DDL with metadata-derived H2 integration tests.
- StAEDI 1.26.4 X12 streaming envelopes, separator detection, bounded transaction buffers, structured envelope findings, and fixed-width ISA serialization.
- Original 850/997/999 baseline guides, versioned database loading, SNIP 1–2 validation, syntax notes, and the documented situational rule language.
- TA1, 997 and 999 builders with acknowledgement levels, positional situational context, reply envelopes, and inbound acknowledgement parsing.
- Atomic RDBMS control-number allocation with first-use creation, wrap/exhaustion handling, and interchange/group/transaction duplicate detection.
- Database payload storage with AES-256-GCM envelope encryption, SHA-256 integrity metadata, key rotation, and replaceable environment-secret resolution.
- Per-instance runtime services and configuration, synthetic generators, exact acknowledgement fixtures, concurrent SQL tests, and payload tests that verify no temporary files are created.
- Repository scaffold to the QRun qbit repository standard: `qbit-build-parent` 2.0.0, QQQ 4.1.0-RC.5, Maven wrapper, Checkstyle, JaCoCo gate, CircleCI qqq-orb 0.6.8, release-candidate workflow, Dependabot, community files.
- `EdiQBitProducer` and `EdiQBitConfig` (table prefix `edi_`, permission base `edi`).
### Fixed
- Inbound apply no longer fails with a null-pointer error on a target table that has no associations (flat tables such as the 214 status sample).
+17 -54
View File
@@ -1,62 +1,25 @@
# CLAUDE.md
# qbit-template-application
This file provides guidance to Claude Code when working with code in this repository.
GitHub template repository for scaffolding Application QBits (complete mini-applications
with tables, processes, widgets, and UI navigation) on the QQQ platform.
## Project Overview
## QQQ 4.0 baseline — 2026-09-24
**QBit EDI**: a full EDI application qbit for QQQ. Trading partners and agreements, pluggable
transports (AS2, SFTP/FTPS, VAN, HTTPS, email), X12 (004010, 005010; EDIFACT later) parsing and
validation against data-driven implementation guides, 997/999/TA1, versioned maps to QQQ records,
inbound and outbound flows, and HIPAA documents with PHI classification. Design: `docs/DESIGN.md`.
The historical review below predates the 4.0 migration. This template targets Java 21 and the public QQQ 4.0.0 release from Maven Central. The corrected APIs, license references and actual template commands are in README.md and docs/00-getting-started.md. Preserve the documented multi-instance and scaffold limitations.
## Build Commands
## Knowledge base
```bash
./mvnw compile
./mvnw test
./mvnw verify # Checkstyle, tests, JaCoCo: 70% instructions, 90% classes
```
Reviewed dossier and platform knowledge live in the second-brain vault:
Always use the wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM.
- Hub: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/qqq-hub.md`
- This repo's dossier: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/repos/qbit-template-application.md`
- QBit mechanics refresher: `R:/Git.Local/KofTwentyTwo/second-brain/knowledge/qqq/architecture/metadata-model.md`
## Architecture
Reviewed at commit `fb9395ac0d07` (branch `main`, 2026-07-04).
Entry point: `EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>` in
`com.kingsrook.qbits.edi`. Component producers are scanned only in `com.kingsrook.qbits.edi.metadata`.
The package layout per phase is in `docs/DESIGN.md`.
## Invariants
- Data lives in standard visible QQQ tables with record screens, secured by permissions and record locks.
- Processes are table or record actions with plain labels, never in the nav; record actions use the
standard streamed-ETL Review/Result summary.
- One app home that owns the EDI data, built from typed widgets (no `RawHTML`, guardrail G5).
- Inbound transport endpoints are non-UI routes under `/qqq/hooks/edi/...`, never under `/qqq/v1`.
UI endpoints are spec'd `MiddlewareSpecExtension`s under `/qqq/v1/qbit/edi/...` with a committed fragment.
- Records are created and updated through normal QQQ actions as a service account.
- Raw documents and PHI fields are classified (`DataClassification`), stored encrypted.
- Secrets are referenced by environment-variable name only. No real PHI in tests or samples.
- No dependency on another qbit's internals (guardrail G1); notifications go through core `QAlerts`.
## Naming
- Tables: `edi_<name>`, lowercase snake_case; SQL names equal QQQ table names.
- Permissions: full name, `edi.*`.
## Code Style
- Copy the Apache header from `checkstyle/license.txt`; 3-space indent, Allman braces, `return (x);`.
- Flower-box Javadoc on every class and method; fluent `withX()` setters.
- Wrapper types in config and entities; JUnit 5 and AssertJ tests.
## Dependencies
Java 21; parent `com.kingsrook:qbit-build-parent:2.0.0`. Default-active `qqq-snapshot` imports QQQ
`4.1.0-RC.5` ahead of the parent's 4.0.0 BOM (ADR-0007); move to each new RC within a week (G7).
Middleware Javalin is optional. Also StAEDI (X12) and as2-lib with BouncyCastle (AS2), all Apache-2.0/MIT-style licensed.
## Release
CircleCI qqq-orb 0.6.8: `develop` publishes SNAPSHOTs, `release/*` publishes RCs (orb version
bumps; cut by the `release-rc.yml` workflow), `vX.Y.Z` tags publish releases. No manual publishing.
Branches are `feature/*` or `fix/*` only; never push `hotfix/*` or `release/*`.
Key caveat from that review: the scaffold in `src/main` references six qqq APIs that do not
exist in any qqq version (wrong packages for `MetaDataProducerInterface` /
`AbstractWidgetRenderer` / `RenderWidgetInput`, plus nonexistent `QInstance.addAppSection`,
`QProcessMetaData.withInputFields`, `QAppSection.withSourceQBitName`) — it has never compiled,
and CI on `main` is red. Verify against the qqq checkout (`R:/Git.Local/QRunIO/qqq`) before
trusting any API usage shown here.
-3
View File
@@ -1,3 +0,0 @@
# Code of Conduct
See Project level [Code of Conduct](https://github.com/QRun-IO/qqq/blob/develop/CODE_OF_CONDUCT.md)
-3
View File
@@ -1,3 +0,0 @@
# Code Style Guide
* See Project [Code Style Guide](https://github.com/QRun-IO/qqq/blob/develop/CODE_STYLE.md)
-5
View File
@@ -1,5 +0,0 @@
# Contributing
Follow [QQQ contributing guidance](https://github.com/QRun-IO/qqq/blob/develop/CONTRIBUTING.md).
Use Java 21 and `./mvnw verify`, and follow local Checkstyle. Create feature branches from `develop` and target `develop` with PRs. See [AGENTS.md](AGENTS.md) for architecture, naming, security and release conventions, and [the design](https://github.com/QRun-IO/qqq/pull/1137) before extending the qbit.
+1 -1
View File
@@ -176,7 +176,7 @@
END OF TERMS AND CONDITIONS
Copyright 2024 Kingsrook, LLC
Copyright 2022-2025 QRunIO, LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
+3 -3
View File
@@ -1,6 +1,6 @@
QBit EDI
Copyright 2026 Kingsrook, LLC
qBit Template - Application
Copyright 2022-2025 QRunIO, LLC
This product includes software developed at Kingsrook, LLC (https://kingsrook.com/).
This product includes software developed at QRunIO, LLC (https://qrun.io/).
Licensed under the Apache License, Version 2.0.
+42 -259
View File
@@ -1,272 +1,55 @@
# QBit EDI
# QBit Template - Application
EDI for QQQ: trading partners, agreements, X12 validation, encrypted payloads, inbound mapping
and application, and TA1/997/999 acknowledgements. See [the design](docs/DESIGN.md).
Template repository for creating **Application QBits** - complete, self-contained applications or major functional modules for the QQQ framework.
## Status
## When to Use This Template
Phases a1, a2, b and c implement inbound/outbound retail EDI over gateway, SFTP, HTTPS and AS2. Original baseline guides cover
850, 855, 856, 810, 846 and 860 in 004010/005010, plus 997/999. Source records and associations
produce validated, encrypted outbound interchanges, with control-number tracking, retries,
acknowledgement monitoring and operator resend/cancel actions. AS2 (signed, encrypted, compressed, synchronous and
asynchronous MDN, certificate rollover) is built in; email sending remains disabled until the qbit-email API ships.
A runnable synthetic [demo](docs/DEMO.md) includes Next UI and needs no external services.
Use this template when building:
- Complete mini-applications (WMS, OMS, CRM)
- Major functional modules with multiple tables
- QBits that require UI navigation (QAppSection)
- Self-contained features with business logic
## Requirements and installation
## Quick Start
Java 21, QQQ **4.1.0-RC.5**, an RDBMS backend, and `com.kingsrook.qbits:qbit-edi:0.1.0-SNAPSHOT`.
Apply [the PostgreSQL schema](docs/ddl/edi-postgres.sql). Targets and their associations must
share the EDI backend so each transaction set can commit or roll back atomically.
Requires **Java 21**, **Maven 3.8+**, and QQQ **4.0.0**. Create a repository with **Use this template**, customize its Maven coordinates and Java package/classes, then run `mvn clean verify`. Follow the [Getting Started guide](docs/00-getting-started.md) for the full sequence.
```java
new EdiQBitProducer()
.withQBitConfig(new EdiQBitConfig()
.withBackendName("rdbms")
.withSchedulerName("scheduler") // optional
.withSendAsEdiTable("sample_order") // host-defined table, optional manual action
.withServiceSessionSupplier(() -> makeEdiServiceSession()))
.produce(qInstance);
## Structure
```
src/main/java/com/kingsrook/qbits/example/
├── ExampleAppQBitConfig.java
├── ExampleAppQBitProducer.java
├── model/
│ ├── ExampleEntity.java
│ └── ExampleChildEntity.java
├── processes/
│ ├── ExampleProcessStep.java
│ └── ExampleProcessMetaDataProducer.java
└── widgets/
├── ExampleDashboardWidgetRenderer.java
└── ExampleDashboardWidgetMetaDataProducer.java
```
Call `EdiRuntime.current().initialize()` in the host's authorized startup context to install
baseline guides and data-driven schedules. After defining batch/poll settings, call
`OutboundProcesses.installSchedules()` before starting the host scheduler; rebuild/restart scheduling
after changing those settings. Define partners, identities, active agreements, inbound agreement documents
and active maps as normal QQQ records. `makeEdiServiceSession()` is a host function returning
a least-privilege `QSession` with target and association permissions. Applying without a supplier
produces an APPLY exception: `no service session configured`.
## Key Characteristics
The `edi` app has Activity/Setup sections, typed home widgets, record screens and standard
Review/Result actions. Main tables have RC.5 search-field metadata; `EdiIntegrations.mainTables()`
also exposes the list for host search registration.
| Feature | Description |
|---------|-------------|
| QAppSection | Required - provides UI navigation |
| Tables | Multiple interconnected business tables |
| Processes | Full CRUD with business logic |
| Widgets | Dashboard components |
| Security | Permission key support |
| Integration | Hooks for other QBits |
## Tables
## Documentation
There are **26 persisted tables**, whose SQL names equal their QQQ names.
- [Getting Started](docs/00-getting-started.md)
- [Tables and Relationships](docs/01-tables-and-relationships.md)
- [Processes](docs/02-processes.md)
- [Widgets and UI](docs/03-widgets-and-ui.md)
| Area | Tables |
| --- | --- |
| Setup | `edi_partner`, `edi_identity`, `edi_agreement`, `edi_agreement_document`, `edi_control_number`, `edi_transport`, `edi_certificate` |
| Guides | `edi_guide`, `edi_guide_segment`, `edi_guide_element`, `edi_guide_rule`, `edi_code_list`, `edi_code_list_value` |
| Maps | `edi_map`, `edi_map_rule`, `edi_map_sample` |
| Processing | `edi_inbox`, `edi_interchange`, `edi_functional_group`, `edi_transaction`, `edi_transaction_error`, `edi_exception`, `edi_event` |
| Storage/queue | `edi_payload`, `edi_outbound_request`, `edi_outbound_message` |
## See Also
`edi_preview_download` is a **storage-only metadata alias**, not a SQL table. Supporting tables
appear as child lists. Partner-scoped form choices use RC.5 contingent filters, including shared
transports/guides and local certificates/identities.
## Inbound operation
`InboundSink.receive(InboundPayload)` stores encrypted bytes and SHA-256 before returning a durable
inbox record. Transport/external ID retries are idempotent; matching bytes within the 365-day intake
window create DUPLICATE rows. `EdiFileGatewayConsumer` uses the marked internal stand-in contract,
with upload ID plus digest as external ID, and returns `edi_inbox:<id>` without parsing.
Storage failure requests retry in one minute.
`InboxWorker.runOnce()` processes up to 20 messages with compare-and-set claims, fenced database
checkpoints and five-minute stale-claim recovery. Retry delays are 1m, 5m, 15m, 1h and 6h; the next
failure becomes DEAD_LETTER. Each mapped accepted ST applies in its own transaction through QQQ
actions with explicit permission enforcement. ISA15=T applies only with `applyTestDocuments`.
Routing checks ISA identities, qualifiers, standard and usage. Linked gateway usernames must match
the routed partner. Findings and failures become exceptions with occurrence counts and value-free
timeline events. Replies allocate controls and remain QUEUED, recording `ACK_QUEUED`. Incoming
acknowledgements reconcile outbound groups and resolve overdue alerts.
Reprocess restarts the containing inbox from encrypted storage, using original or current guide/map
revisions. Source-position checkpoints and target upsert keys preserve idempotency, stale findings
are replaced, and the same reply is not queued twice. A live PROCESSING claim cannot be reset.
## Maps and preview
EDI paths reuse the guide rule language. Targets support `field` and `association.field`;
wildcards such as `PO1[*]/PO107` produce child records. `matchKeyFields` controls upsert, with
`onMatch` UPDATE/SKIP/EXCEPTION. [MAPS.md](docs/MAPS.md) documents transform arguments.
Resources under `/edi/maps` include inbound 850/860 and outbound 855/856/810/846 maps.
The order, shipment/carton/item, invoice/line and inventory schemas exist **only in test sources**.
Outbound maps support nested association paths; inbound target paths retain the one-association limit.
Preview shows parse counts, SNIP findings, the would-be ack, mapped JSON, expected-result and
previous-revision comparisons. It writes no target records. PHI previews suppress document values.
A session-authorized encrypted JSON report is downloadable. Select a stored sample or upload
synthetic EDI on the standard Review step. Browser uploads require the host's RC.5 Javalin
`uploadedFileArchiveTableName` to be `edi_preview_download` and multipart handling without disk
staging. The alias encrypts streams and persists samples; this process accepts no filesystem archive.
Samples must contain synthetic data only.
## Permissions
Full table permissions include `edi.partner.read|insert|edit|delete`, `edi.map.*`,
`edi.transaction.read` and `edi.exception.read|edit`. Multiword names use dots
(e.g. `edi.map.rule.read`). Runtime tables disable people-facing mutations.
| Action | Permission |
| --- | --- |
| Reprocess | `edi.reprocess` |
| Preview Map | `edi.map.preview` |
| Assign / Ignore / Mark Resolved | `edi.exception.assign` / `edi.exception.ignore` / `edi.exception.resolve` |
| Create Partner Override / Activate Guide | `edi.guide.override` / `edi.guide.activate` |
| Activate Map | `edi.map.activate` |
| Send As EDI | `edi.send` |
| Resend / Resend As New | `edi.resend` |
| Cancel | `edi.cancel` |
| Test Connection | `edi.transport.test` |
| Promote Certificate | `edi.certificate.promote` |
| Export Public Certificate | `edi.certificate.read` |
| Scheduled workers / ESB enqueue | `edi.admin` |
Partner locks use `edi_partner` with `edi.admin` all-access. Home queries explicitly enforce
the viewing user's permissions. Processes are table/record actions, never navigation entries.
## Configuration
| Setting | Default / behavior |
| --- | --- |
| `backendName` | Required host RDBMS backend |
| `appName`, `appLabel`, `parentAppName` | `edi`, `EDI`, no parent |
| `schedulerName` | Null; configure for 15-second polling |
| `serviceSessionSupplier` | Null; required for inbound application and outbound source reads/mapping |
| `withSendAsEdiTable(name)` | Opt a host table into Send As EDI |
| `partnerLocks` | `false` |
| `maxSegmentsPerTransaction` | 100000 |
| `payloadStore` | `DATABASE` or `OBJECT_STORAGE` |
| `payloadBucketAlias` | `edi` |
| `secretsResolver` | Environment-variable lookup |
| `publicBaseUrl` | Null; the deployment base URL (for example `https://edi.example.com`), required for ASYNC AS2 receipts |
| `clock` | UTC system clock |
| `esbDestinationName` | Null; optional core ESB destination |
Set `QQQ_EDI_PAYLOAD_KEY` to a base64-encoded 32-byte master key; retain the old key in
`QQQ_EDI_PAYLOAD_KEY_PREVIOUS` during rotation. AES-256-GCM envelope encryption uses random data
keys and SHA-256 verification. Both stores use the same envelope and currently buffer a payload
in memory. Database storage remains the default for demos/tests.
For object storage, add/install optional `qbit-object-storage:0.1.0-RC.2`, configure its bucket
alias and select `OBJECT_STORAGE`. EDI uses only its public API and system access, with keys
`edi/yyyy/MM/dd/kind/uuid`. Its transitive QQQ dependencies are excluded to retain RC.5.
Optional `qqq-esb` publishes setup/activity events after commit. Document events contain only ID
and status because RC.5's standard factory otherwise copies PHI fields. Alert names are
`edi.inbox.dead-letter`, `edi.ack.overdue`, `edi.ack.rejected`, `edi.send.failed`,
`edi.certificate.expiring`, `edi.certificate.expired` and `edi.mdn.overdue` (RC.5 requires dotted names).
RC.5 lacks the planned core `DataClassification` API. `EdiClassifications` marks PHI/PII/confidential
fields and PHI fields deny AI exposure. Hosts must supply secured-data protection, database
encryption at rest and access policies for PHI columns; markers alone do not mask fields.
The home reports installed integration and storage state.
## Outbound and transports
Active OUTBOUND agreement documents select the source table, map, guide and trigger. RECORD_CHANGE
uses core's instance listener and the writer's transaction. Source and EDI tables must share the
backend. `triggerFilter` is a serialized QQQ `QQueryFilter`; MANUAL also honors that filter.
RC.5 catches listener errors without failing the source write: successful enqueues roll back with
the source transaction, but hosts must monitor failed listener execution.
`edi_outbound_enqueue` is the ESB boundary. Configure an RC.5 `EsbTrigger` to invoke it with
`sourceTableName`, `sourceRecordId` and `eventId`; broker setup belongs to the host. Event IDs are
unique per agreement document. RC.5's consumer runner uses JMS, so broker-free acceptance calls
this process directly.
`edi_outbound_worker` and `edi_send_worker` run every 15 seconds when a scheduler is configured.
IMMEDIATE produces one interchange per request. BATCH cuts up to 100 requests for one agreement
document at its UTC `batchSchedule` into one group/interchange. Mapping/enveloping and sending
use database row locks to fence replicas; crashes release the lock. Remote delivery is at least
once: a lost connection after remote acceptance can resend the same bytes and control numbers.
Retry waits are 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h, capped at the last delay; `maxAttempts` defaults
to 8. Permanent transport rejection is FAILED; retry exhaustion is DEAD_LETTER. Both open a
TRANSPORT exception and raise `edi.send.failed`.
| Transport | Behavior and settings |
| --- | --- |
| GATEWAY | `gatewayEndpointName`; marked `FileGatewayOutbox` stand-in with a provider registration hook. Put is SENT; `EdiFileGatewayConsumer.onDownloaded` is DELIVERED. |
| SFTP | MINA client from core's filesystem module; required `sftpHostKeyFingerprint` in MINA SHA256 format, username and password/private-key env name. Streams upload to `.tmp`, then rename. `sftpPollCron` schedules remote pickup; `sftpFilePattern` is a Java regex (default `.*\.x12`). DELETE/MOVE occurs only after durable inbox commit. |
| HTTPS | POST with NONE/BASIC/BEARER/HMAC; TLS required except loopback. BASIC's secret is `user:password`; BEARER's is the token; HMAC uses core Standard Webhooks `whsec_` format. `httpsHeadersJson` holds nonsecret headers only. Redirects are refused; 2xx is DELIVERED, 429/5xx retry. |
| AS2 | See [AS2](#as2). |
| EMAIL | Settings validation only. Sending returns a permanent unavailable result until the qbit-email API ships. |
Optional Javalin registers `POST /qqq/hooks/edi/https/{transport}`. `httpsInboundAuth` must be
BASIC, BEARER or HMAC with `httpsSecretEnvVar`. It authenticates exact bytes, caps intake at
50 MiB and returns 202 only after durable storage. HMAC verifies timestamps and deduplicates by
transport plus webhook ID. TLS terminates at the host ingress.
The transport registry is `EdiTransportPluginsMetaData`, attached to `QInstance`. Hosts can
register/replace implementations with `withPlugin`. All transports open encrypted PayloadStore
streams; production code never writes local files.
Ack deadlines start on the first successful send. `edi_ack_monitor` runs every five minutes,
raises overdue alerts and exceptions, and checks certificate expiry at 30/14/7 days and expiry.
A functional ack resolves its alert and exception; TA1 updates interchange acknowledgement only.
Resend retains archived bytes/control numbers. Resend As New re-envelopes the archived document
without re-running its map and links `originalInterchangeId`. Cancel accepts QUEUED/RETRY_WAIT only.
Existing a2 databases need `edi_interchange.original_interchange_id INTEGER` and a unique index
on `edi_outbound_request(agreement_document_id, event_id)` in addition to their prior schema.
## AS2
`As2Transport` (type `AS2`) sends and receives AS2 (RFC 4130) with S/MIME signing and encryption
(SHA-256/384/512, AES-128/256 CBC or GCM; SHA1 and 3DES only when set explicitly and flagged by
the read-only `weakCrypto` field), optional zlib compression, and synchronous or asynchronous
signed MDNs. Everything is in memory: no keystore, key or temporary file is ever written.
**Transport settings** (`edi_transport`, type AS2): `as2LocalId`, `as2PartnerId`, `as2Url` (HTTPS;
plain HTTP only on loopback), `as2Subject`, `signAlgorithm` (blank = unsigned), `encryptAlgorithm`
(blank = unencrypted), `compress`, `mdnMode` (`SYNC` default, `ASYNC`, `NONE`), `mdnSigned`,
`localCertificateId` (ours), `partnerCertificateId`, optional `partnerTlsCertificateId` (pins the
partner TLS certificate), and optional `httpAuthUserEnvVar` / `httpAuthPasswordEnvVar` for HTTP
Basic. For inbound, a set `signAlgorithm` / `encryptAlgorithm` means signed / encrypted messages are
required (otherwise `insufficient-message-security`). A VAN is an AS2 transport with `isVan` true
and no `partnerId`; its partner certificate belongs to the VAN operator's partner record, and
routing to trading partners stays by ISA identity.
**Certificates** (`edi_certificate`): paste the public PEM only; subject, issuer, serial,
SHA-256 fingerprint and validity are parsed on save, and a pasted private key is refused. Our private
key is never stored: `privateKeyEnvVar` names an environment variable holding a PEM PKCS#8 key or a
base64 PKCS#12 store (with `privateKeyPasswordEnvVar` for its password). A missing or mismatched
variable fails plainly, naming only the variable. Status `ACTIVE` signs and encrypts outbound;
inbound accepts `ACTIVE` and `NEXT` (ours for decryption, the partner's for verification), so a
rollover is: add the new certificate as `NEXT`, tell the partner, then run **Promote Certificate**
(`NEXT` becomes `ACTIVE`, the previous `ACTIVE` of the same partner and usage becomes `RETIRED`).
**Export Public Certificate** downloads the PEM to send to a partner. The monitor alerts at 30, 14
and 7 days before expiry and at expiry for `ACTIVE` and `NEXT` certificates.
**Hook URLs** (outside `/qqq/v1`, authenticated per transport by AS2 identity, signature and
certificate; give your partner the first, and the second is sent in `Receipt-Delivery-Option`):
| URL | Purpose |
| --- | --- |
| `POST {base}/qqq/hooks/edi/as2/{transport}` | Receive messages; answers with the MDN, or 200 and a queued asynchronous MDN |
| `POST {base}/qqq/hooks/edi/as2/{transport}/mdn` | Receive asynchronous MDNs for messages we sent |
`{transport}` is the transport `name`. Set `EdiQBitConfig.withPublicBaseUrl(...)` so `ASYNC`
transports can announce the second URL. Bodies are limited by the largest `maxInterchangeBytes` of
the agreements on the transport (50 MB default). A retransmitted Message-ID is answered with the
original MDN and is not reprocessed; a bad signature, decryption or MIC returns an MDN with
`processed/error: authentication-failed`, `decryption-failed` or `integrity-check-failed` and creates
no inbox row; a storage failure returns `unexpected-processing-error` so the partner retries. Every MDN
sent or received is kept in the read-only `edi_as2_mdn` table (AS2 MDNs child lists on the
outbound message and the inbound message) with the raw MDN in the encrypted payload store.
`ASYNC` messages stay SENT until the MDN arrives; the `edi_as2_mdn_sender` step delivers the MDNs we
owe, retrying with the send backoff, and `edi.mdn.overdue` warns when an `ASYNC` MDN is later than
the agreement document `ackDueMinutes` (60 when unset).
Existing databases need the new `edi_as2_mdn` table, `edi_outbound_message.transport_mic` and
`edi_transport.weak_crypto` (see `docs/ddl/edi-postgres.sql`).
## Build and verification
```bash
./mvnw -B verify
```
Use the wrapper (Maven 3.9.11); Maven 3.10 cannot read the parent POM. Verification runs Checkstyle,
synthetic H2 tests and JaCoCo (70% instructions, 90% classes). Object-storage key/envelope metadata
are unit-tested. Live S3, a deployed gateway/scheduler and browser layout are not exercised.
The public object-storage facade is final with restricted constructors; tests avoid provider internals.
## License
Apache License 2.0. See [LICENSE](LICENSE) and [NOTICE](NOTICE).
- [qbit-template-extension](https://github.com/QRun-IO/qbit-template-extension) - For infrastructure extensions
- [qbit-template-data](https://github.com/QRun-IO/qbit-template-data) - For reference data providers
-4
View File
@@ -1,4 +0,0 @@
# Security Policy
* See Project [Security Policy](https://github.com/QRun-IO/qqq/blob/develop/SECURITY.md)
* Report a vulnerability privately through this repository's **Security** tab ("Report a vulnerability").
-246
View File
@@ -1,246 +0,0 @@
<?xml version="1.0"?>
<!--
~ QQQ - Low-code Application Framework for Engineers.
~ Copyright (C) 2021-2022. Kingsrook, LLC
~ 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
~ contact@kingsrook.com
~ https://github.com/Kingsrook/
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ https://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<!DOCTYPE module PUBLIC
"-//Puppy Crawl//DTD Check Configuration 1.3//EN"
"http://checkstyle.sourceforge.net/dtds/configuration_1_3.dtd">
<!--
See reference at https://checkstyle.sourceforge.io/checks.html
-->
<module name = "Checker">
<property name="charset" value="UTF-8"/>
<property name="severity" value="warning"/>
<property name="fileExtensions" value="java, properties, xml"/>
<!-- Checks for whitespace -->
<!-- See http://checkstyle.sf.net/config_whitespace.html -->
<module name="FileTabCharacter">
<property name="eachLine" value="true"/>
</module>
<!--
<module name="LineLength">
<property name="max" value="100"/>
<property name="ignorePattern" value="^package.*|^import.*|a href|href|http://|https://|ftp://"/>
</module>
-->
<module name="TreeWalker">
<module name="SuppressWarningsHolder"/>
<module name="OuterTypeFilename"/>
<module name="IllegalTokenText">
<property name="tokens" value="STRING_LITERAL, CHAR_LITERAL"/>
<property name="format" value="\\u00(09|0(a|A)|0(c|C)|0(d|D)|22|27|5(C|c))|\\(0(10|11|12|14|15|42|47)|134)"/>
<property name="message" value="Consider using special escape sequence instead of octal value or Unicode escaped value."/>
</module>
<module name="AvoidEscapedUnicodeCharacters">
<property name="allowEscapesForControlCharacters" value="true"/>
<property name="allowByTailComment" value="true"/>
<property name="allowNonPrintableEscapes" value="true"/>
</module>
<module name="AvoidStarImport"/>
<module name="OneTopLevelClass"/>
<module name="NoLineWrap"/>
<module name="EmptyBlock">
<property name="option" value="TEXT"/>
<property name="tokens" value="LITERAL_TRY, LITERAL_FINALLY, LITERAL_IF, LITERAL_ELSE, LITERAL_SWITCH"/>
</module>
<module name="NeedBraces"/>
<module name="LeftCurly">
<property name="option" value="nl"/>
</module>
<module name="RightCurly">
<property name="option" value="alone"/>
</module>
<!--
<module name="WhitespaceAround">
<property name="allowEmptyConstructors" value="true"/>
<property name="allowEmptyMethods" value="true"/>
<property name="allowEmptyTypes" value="true"/>
<property name="allowEmptyLoops" value="true"/>
<message key="ws.notFollowed"
value="WhitespaceAround: ''{0}'' is not followed by whitespace. Empty blocks may only be represented as '{}' when not part of a multi-block statement (4.1.3)"/>
<message key="ws.notPreceded"
value="WhitespaceAround: ''{0}'' is not preceded with whitespace."/>
</module>
-->
<module name="OneStatementPerLine"/>
<module name="MultipleVariableDeclarations"/>
<module name="ArrayTypeStyle"/>
<module name="MissingSwitchDefault"/>
<module name="FallThrough"/>
<module name="UpperEll"/>
<module name="ModifierOrder"/>
<module name="EmptyLineSeparator">
<property name="allowNoEmptyLineBetweenFields" value="true"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapDot"/>
<property name="tokens" value="DOT"/>
<property name="option" value="nl"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapComma"/>
<property name="tokens" value="COMMA"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ELLIPSIS is EOL until https://github.com/google/styleguide/issues/258 -->
<property name="id" value="SeparatorWrapEllipsis"/>
<property name="tokens" value="ELLIPSIS"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<!-- ARRAY_DECLARATOR is EOL until https://github.com/google/styleguide/issues/259 -->
<property name="id" value="SeparatorWrapArrayDeclarator"/>
<property name="tokens" value="ARRAY_DECLARATOR"/>
<property name="option" value="EOL"/>
</module>
<module name="SeparatorWrap">
<property name="id" value="SeparatorWrapMethodRef"/>
<property name="tokens" value="METHOD_REF"/>
<property name="option" value="nl"/>
</module>
<module name="PackageName">
<property name="format" value="^[a-z]+(\.[a-z][a-z0-9]*)*$"/>
<message key="name.invalidPattern"
value="Package name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="TypeName">
<message key="name.invalidPattern"
value="Type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MemberName">
<property name="format" value="^[_a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Member name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="CatchParameterName">
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Catch parameter name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="LocalVariableName">
<property name="tokens" value="VARIABLE_DEF"/>
<property name="format" value="^[a-zA-Z]([a-z0-9A-Z][a-zA-Z0-9]*)?$"/>
<message key="name.invalidPattern" value="Local variable name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="ClassTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Class type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="MethodTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Method type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="InterfaceTypeParameterName">
<property name="format" value="(^[A-Z][0-9]?)$|([A-Z][_A-Z0-9]*$)"/>
<message key="name.invalidPattern" value="Interface type name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="NoFinalizer"/>
<module name="GenericWhitespace">
<message key="ws.followed" value="GenericWhitespace ''{0}'' is followed by whitespace."/>
<message key="ws.preceded" value="GenericWhitespace ''{0}'' is preceded with whitespace."/>
<message key="ws.illegalFollow" value="GenericWhitespace ''{0}'' should followed by whitespace."/>
<message key="ws.notPreceded" value="GenericWhitespace ''{0}'' is not preceded with whitespace."/>
</module>
<module name="Indentation">
<property name="basicOffset" value="3"/>
<property name="braceAdjustment" value="0"/>
<property name="caseIndent" value="3"/>
<property name="throwsIndent" value="6"/>
<property name="lineWrappingIndentation" value="3"/>
<property name="arrayInitIndent" value="6"/>
</module>
<!--
<module name="AbbreviationAsWordInName">
<property name="ignoreFinal" value="false"/>
<property name="allowedAbbreviationLength" value="1"/>
</module>
<module name="OverloadMethodsDeclarationOrder"/>
<module name="VariableDeclarationUsageDistance"/>
<module name="CustomImportOrder">
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="true"/>
<property name="customImportOrderRules" value="STATIC###THIRD_PARTY_PACKAGE"/>
</module>
-->
<module name="CustomImportOrder">
<property name="customImportOrderRules" value="SPECIAL_IMPORTS###STANDARD_JAVA_PACKAGE###THIRD_PARTY_PACKAGE###STATIC"/>
<property name="specialImportsRegExp" value="^javax\."/>
<property name="standardPackageRegExp" value="^java\."/>
<property name="sortImportsInGroupAlphabetically" value="true"/>
<property name="separateLineBetweenGroups" value="false"/>
</module>
<module name="MethodParamPad"/>
<module name="NoWhitespaceBefore">
<property name="tokens" value="COMMA, SEMI, POST_INC, POST_DEC, DOT, ELLIPSIS, METHOD_REF"/>
<property name="allowLineBreaks" value="true"/>
</module>
<module name="ParenPad"/>
<module name="OperatorWrap">
<property name="option" value="NL"/>
<property name="tokens" value="BAND, BOR, BSR, BXOR, DIV, EQUAL, GE, GT, LAND, LE, LITERAL_INSTANCEOF, LOR, LT, MINUS, MOD, NOT_EQUAL, PLUS, QUESTION, SL, SR, STAR, METHOD_REF "/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationMostCases"/>
<property name="tokens" value="CLASS_DEF, INTERFACE_DEF, ENUM_DEF, METHOD_DEF, CTOR_DEF"/>
</module>
<module name="AnnotationLocation">
<property name="id" value="AnnotationLocationVariables"/>
<property name="tokens" value="VARIABLE_DEF"/>
<property name="allowSamelineMultipleAnnotations" value="true"/>
</module>
<module name="JavadocMethod">
<property name="allowMissingParamTags" value="true"/>
<property name="allowMissingReturnTag" value="true"/>
<property name="allowedAnnotations" value="Override, Test"/>
</module>
<module name="MissingJavadocMethod">
<property name="scope" value="private"/>
</module>
<module name="MissingJavadocType">
<property name="scope" value="private"/>
</module>
<module name="MethodName">
<property name="format" value="^[a-z][a-zA-Z0-9_]*$"/>
<message key="name.invalidPattern"
value="Method name ''{0}'' must match pattern ''{1}''."/>
</module>
<module name="EmptyCatchBlock">
<property name="exceptionVariableName" value="expected"/>
</module>
<module name="CommentsIndentation"/>
<!-- this would be nice, but requires the the @inheritDoc javadoc tag... -->
<module name="MissingOverride"/>
</module>
<module name="Header">
<property name="headerFile" value="checkstyle/license.txt"/>
<property name="fileExtensions" value="java"/>
<property name="ignoreLines" value="3"/>
</module>
<module name="SuppressWarningsFilter"/>
</module>
-19
View File
@@ -1,19 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
+30
View File
@@ -0,0 +1,30 @@
# Getting Started
Targets Java 21, Maven 3.8 or later, and QQQ 4.0.0. Maven resolves the framework dependencies from Maven Central.
1. Choose **Use this template** on GitHub, then clone the repository you created.
2. In `pom.xml`, set your own `groupId`, `artifactId`, name, description, and version.
3. Use your IDE's package refactoring to rename `com.kingsrook.qbits.example` throughout `src/`. Rename the `Example*` classes and update their imports and references with the IDE's rename refactoring.
4. Update the QBit producer's `GROUP_ID`, `ARTIFACT_ID`, and `VERSION` constants to match your project. Give the metadata names their own stable names before combining the QBit with other examples.
5. Build the generated project:
```bash
mvn clean verify
```
The repository provides example Java sources; customization uses normal package/class refactoring. Keep example coordinates unpublished. After customizing, add tests for your QBit's behavior before setting up its publishing workflow.
## Register with a host application
The following uses the original example names; substitute the names chosen above:
```java
new ExampleAppQBitProducer()
.withConfig(new ExampleAppQBitConfig()
.withBackendName("rdbms"))
.produce(qInstance, "my-application");
```
Register the configured backend in the host first. If you add table prefixes or multiple QBit instances, update related table references, possible-value-source names, and process/widget names consistently. A table prefix alone does not namespace all metadata.
[Tables and Relationships](01-tables-and-relationships.md) describes the model; the `processes/` and `widgets/` directories contain the remaining examples.
+50
View File
@@ -0,0 +1,50 @@
# Tables and Relationships
Application QBits typically have multiple interconnected tables representing a business domain.
## Entity Design
Use `@QMetaDataProducingEntity` for automatic metadata generation:
```java
@QMetaDataProducingEntity(producePossibleValueSource = true)
public class Order extends QRecordEntity
{
public static final String TABLE_NAME = "order";
@QField(isPrimaryKey = true)
private Integer id;
@QField(isRequired = true, possibleValueSourceName = "customer")
private Integer customerId;
@QField(isRequired = true)
private String status;
}
```
## Relationships
Define foreign keys using `possibleValueSourceName`:
```java
@QField(isRequired = true, possibleValueSourceName = "exampleEntity")
private Integer exampleEntityId;
```
## Table Prefixing
Application QBits support optional table prefixing:
```java
// Without prefix: order, orderLine
// With prefix "sales": sales_order, sales_orderLine
new MyAppQBitProducer()
.withConfig(new MyAppQBitConfig()
.withBackendName("rdbms")
.withTableNamePrefix("sales"))
.produce(qInstance, "sales-app");
```
The producer applies the prefix to table names. When extending this pattern, also update possible-value-source names, relationship targets, process tables, and navigation references; table renaming alone does not provide complete multi-instance support.
+7
View File
@@ -0,0 +1,7 @@
# Processes
Start with [ExampleProcessMetaDataProducer](../src/main/java/com/kingsrook/qbits/example/processes/ExampleProcessMetaDataProducer.java) and [ExampleProcessStep](../src/main/java/com/kingsrook/qbits/example/processes/ExampleProcessStep.java). The producer defines the process, its table, inputs, and backend step; the step transforms `PENDING` records to `PROCESSED` and returns them in the output.
In QQQ 4.0, import `MetaDataProducerInterface` from `com.kingsrook.qqq.backend.core.model.metadata`. Define process inputs through `withInputMetaData(new QFunctionInputMetaData().withField(...))`. An `AbstractTransformStep` must implement both `runOnePage(RunBackendStepInput, RunBackendStepOutput)` and `getProcessSummary(RunBackendStepOutput, boolean)`; keep the example's implementations when renaming it.
To make the process discoverable in navigation, add its registered name to a `QAppSection` through `withProcesses(List.of(ExampleProcessMetaDataProducer.NAME))`, then include the section in a `QAppMetaData` registered with `qInstance.addApp(...)`. Use the actual registered name if you introduce a prefix. Add tests for record changes and failure cases when replacing the example business logic.
+23
View File
@@ -0,0 +1,23 @@
# Widgets and UI
The application producer registers a `QAppMetaData` containing a `QAppSection`. The section references metadata names already registered in the instance; it is not registered directly on `QInstance`.
```java
QAppSection section = new QAppSection()
.withName("exampleSection")
.withLabel("Example Application")
.withTables(List.of(ExampleEntity.TABLE_NAME, ExampleChildEntity.TABLE_NAME))
.withProcesses(List.of(ExampleProcessMetaDataProducer.NAME))
.withWidgets(List.of(ExampleDashboardWidgetMetaDataProducer.NAME));
qInstance.addApp(new QAppMetaData()
.withName("exampleApp")
.withLabel("Example Application")
.withSection(section));
```
Use [ExampleAppQBitProducer](../src/main/java/com/kingsrook/qbits/example/ExampleAppQBitProducer.java) as the starting point. Keep these references consistent when enabling/disabling tables or introducing prefixes.
The widget has two parts: [its metadata producer](../src/main/java/com/kingsrook/qbits/example/widgets/ExampleDashboardWidgetMetaDataProducer.java) declares a statistics widget and its renderer, and [its renderer](../src/main/java/com/kingsrook/qbits/example/widgets/ExampleDashboardWidgetRenderer.java) returns a `RenderWidgetOutput` containing `StatisticsData`. Replace the example count with a query or calculation. Choose a widget type that matches the returned data model.
For QQQ 4.0, `AbstractWidgetRenderer` is in `com.kingsrook.qqq.backend.core.actions.dashboard.widgets`; `RenderWidgetInput` and `RenderWidgetOutput` are in `com.kingsrook.qqq.backend.core.model.actions.widgets`.
-60
View File
@@ -1,60 +0,0 @@
# EDI local demo
From this worktree with Java 21:
```powershell
./mvnw.cmd -B test-compile exec:java '-Dexec.classpathScope=test' '-Dexec.mainClass=com.kingsrook.qbits.edi.demo.EdiDemoServer' '-Dexec.args=8099'
```
Open http://localhost:8099/app/edi. Stop with Ctrl+C. On Unix use `./mvnw`.
Next UI table routes are `/app/<tableName>` and record routes are `/app/<tableName>/<id>`.
Each launch creates a fresh in-memory H2 database, an in-memory synthetic payload key and a
fully anonymous demo session. No external credentials or services are required. Demo auth is
in test sources only and must not be installed in a real host.
The seed includes Northwind Retail (gateway), Harbor Logistics (SFTP) and Summit Market (HTTPS),
with codes NORTHWIND, HARBOR and SUMMIT and synthetic contacts at `example.invalid`.
It includes identities/agreements, guides/maps, orders with lines, shipments with cartons/items,
invoices and inventory. Forty synthetic inbound documents go through intake and the real inbox worker:
850s, 860s, validation errors, an unknown sender and a byte duplicate. Northwind's 855/856/810/846
pass the outbound worker and an in-memory gateway; incoming 997s reconcile some sent groups.
An overdue group, an exhausted send and several open exceptions provide operational examples.
Public self-signed certificates are generated in memory (two for expiry alerts, three for AS2); private keys are never persisted.
Pacific Wholesale (code PACIFIC) is the AS2 partner. An in-process BouncyCastle peer listens on a
random loopback port and the seed generates synthetic RSA certificates in memory (our ACTIVE and NEXT
certificates and the partner's; the private keys live only in the demo secrets map and are never
written to disk). The seed sends three 850s to the application's own AS2 hook (signed, encrypted;
two with base64 transfer encoding), which answers with signed MDNs (`edi_as2_mdn` rows, shown as
"AS2 MDNs" on the inbound message); the 997s go back over AS2 as well. An 855 built from a source record is sent
over AS2 to the peer, which verifies the signature and MIC and returns a signed synchronous MDN
(the outbound message becomes DELIVERED with its `transport_mic`). One more message goes to the
asynchronous transport `pacific-as2-async`: it stays SENT until the peer posts the MDN to
`/qqq/hooks/edi/as2/pacific-as2-async/mdn`. The running demo serves
`POST /qqq/hooks/edi/as2/pacific-as2` (and `.../mdn`) on the same port; Test Connection,
Export Public Certificate and Promote Certificate (on `demo-as2-next`) work on the AS2 records.
Phase d adds warehouse and logistics traffic, seeded last so the retail scenarios above are
unchanged. Harbor Logistics (the 3PL) gets a gateway OUTBOX transport (the partner pulls over SFTP)
and sends three valid 940 shipping orders and one with a bad unit; the orders and lines land in
`sample_wms_order`, and two shipments (one short) go out as 945s. A fourth partner, Ridgeline Freight
(code RIDGELINE, gateway transport), receives two 204 load tenders (the second a change) and sends
four 214 status events (one rejected with a bad status code) that build `sample_shipment_status`.
The in-memory gateway accepts every send, and the rejected documents produce rejecting 997s.
The SFTP/HTTPS partner settings are illustrative `.invalid` destinations. Their agreements have
no outbound transport, so they cannot contact a real partner; resulting acknowledgement send
retries are visible. Northwind alone has configured outbound documents. The in-memory gateway
is restored after the intentional dead-letter scenario. Standard Send As EDI, Test Connection,
Resend, Resend As New and Cancel actions are available. The gateway stores bytes in memory;
it does not expose a separate partner download UI.
Acceptance boots a random-port QApplicationJavalinServer, loads Next UI, renders and serializes
every registered widget through core RenderWidgetAction/JsonUtils, and POSTs every home and
child-list widget over HTTP using seeded parent records. It also checks configuration JSON,
metadata, table queries, labels, field order, foreign-key value sources and PHI-safe record labels.
Next UI defaults table queries to descending ID (newest records first); activity child lists use
descending creation date and ID. The home exception queue puts OPEN before IN_PROGRESS, newest
first within each status. RC.5 has no table-level default-sort metadata to override Next's sort.
Inbound message labels use external IDs because filenames are classified as PHI.
Light/dark visual layout and production scheduler/gateway deployment remain host acceptance.
-979
View File
@@ -1,979 +0,0 @@
# QBit EDI: design
Status: **Approved** (owner, 2026-10-13: spec first, then build) · drafted 2026-10-13 · Author:
James Maes (drafted with Claude)
`QRun-IO/qbit-edi` (Apache-2.0) is a full EDI application qbit for QQQ. It handles the transports
(AS2, SFTP, VAN mailboxes, HTTPS, email), carries the EDI documents and their implementation guides
as data, runs inbound and outbound flows with 997, 999 and TA1 acknowledgements, and lets each
trading partner's customizations be expressed as versioned data rather than code.
## Owner request (verbatim)
> For EDI, i guess that would be more of a normal application qbit that would just handle all of
> the transport methods (AS2, etc) and then have all of the edi documents built in, with handling
> for edi flows, 997s etc and handling customizations of them, etc.
## Owner decisions
> **Owner decisions (2026-10-12): all recommendations accepted.** Every open question in section 17
> is decided as this spec recommends ("they are approved. we will adjust as needed once we can view
> them working").
**Owner decisions (2026-10-13):**
- Spec first, then build, in the phases of section 16.
- **First documents (X12):**
- retail and supply chain: 850, 855, 856, 810, 846, 860;
- warehouse and logistics: 940, 945, 214, 204;
- healthcare (HIPAA): 837 (P and I), 835, 270/271, 276/277, 834;
- acknowledgements: 997, 999, TA1.
- **Versions:** X12 004010 and 005010, with the 005010 HIPAA TR3s for healthcare. EDIFACT is
designed in now and built last.
- **No files or filesystem: just the database and S3** ("there are no files or filesystem - just
the database and s3"). Raw EDI documents, AS2 messages and MDNs, and certificates live in object
storage and the database, never on local disk. Private keys come from secrets (environment
variables). Every service is stateless, so any replica can process any message.
- **Inbound SFTP** comes through qbit-file-gateway (SFTP only in v1, no FTPS, owner decision),
using its published `api` package (section 5.3).
- **No kof22 adoption.** kof22 does not need EDI. The demo runs in `qqq-sample-project` (section
15).
## Mandatory standards that shape this design
- Data lives in standard, visible QQQ tables with full record screens, secured by permissions and
record locks. Tables are `edi_*`; permissions use the full name `edi.*`.
- Processes are table or record actions with plain labels, never in the nav. Record actions use the
standard streamed-ETL Review and Result summary lines; no custom dry-run forms.
- One app home that owns the EDI data, built from typed widgets (no `RawHTML`, guardrail G5). The
nav lists only the main tables; sub-tables are child lists on their parents.
- The API matches the admin. Main tables publish ESB table events and are searchable in Quick
Search.
- UI endpoints are spec'd `MiddlewareSpecExtension`s under `/qqq/v1/qbit/edi/...` with a committed
fragment and a contract test (G3). Machine endpoints (AS2, HTTPS receive, MDNs) are non-UI routes
under `/qqq/hooks/edi/...`.
- No dependency on another qbit's internals (G1). EDI depends only on the published `api` packages
of qbit-object-storage and qbit-file-gateway, both optional. Alerts and notifications go through
core `QAlerts`.
- No stand-in without `STAND-IN(core=..., removeBy=...)` (G4). Pinned core baseline, no SNAPSHOT on
`develop` (G7).
- Readable at all widths, light and dark. Real data only on the home.
## 1. Goals and non-goals
**Goals**
1. Exchange X12 with any trading partner over AS2, SFTP, a VAN mailbox, HTTPS or email, configured
as data per partner.
2. Never lose a document: store the raw bytes first (encrypted), then process from a durable inbox
with retries and a dead letter. Every step is idempotent and replayable.
3. Validate against implementation guides that are data (segments, elements, usage, conditional
rules, code lists), per standard and version, with versioned per-partner overrides.
4. Acknowledge automatically (TA1, 997, 999), and track the acknowledgements we expect from
partners, alerting when they are overdue.
5. Map documents to and from QQQ records with versioned, data-driven maps, and apply inbound
documents through normal QQQ actions as a service account, so table permissions, validation,
customizers, audit and ESB events all apply.
6. Make operations visible: a document timeline, search by business keys (PO, ASN, invoice and
claim numbers), an exception work queue, resend and reprocess, and a home showing volumes,
overdue acks and errors.
7. Carry PHI safely: classify healthcare content as PHI so the secured-data tiers and the canary
egress tests apply, and encrypt raw documents at rest.
**Non-goals (v1)**
- A VAN service of our own. We connect to VANs as mailboxes.
- Business-level responses beyond acknowledgements (824 Application Advice, 864 text). The model
leaves room for them (section 9.6).
- Shipping licensed X12 or TR3 text (section 7.4).
- CAQH CORE real-time 270/271 (SOAP and MIME envelope). Batch 270/271 is in scope; CORE
connectivity is a later transport plugin (section 5.6).
## 2. Architecture
```
partner ──AS2──► /qqq/hooks/edi/as2/{transport} ─┐
partner ──SFTP─► qbit-file-gateway INBOX ("edi") ─┤
VAN mailbox ◄─poll── SFTP poller (scheduled) ─┤ 1. store raw bytes (object storage, encrypted)
partner ──HTTPS► /qqq/hooks/edi/https/{transport} ─┤ 2. insert edi_inbox row (PENDING), return
email (qbit-email inbound, later) ─┘ (MDN / 200 / FileAcceptance.accepted)
│
edi_inbox worker (scheduled, claims rows, retries, dead letter)
▼
split interchanges ─► envelope check, TA1 ─► partner + agreement lookup ─► duplicate check
─► per ST: guide validation (StAEDI + guide rules) ─► 997/999 ─► map ─► apply as service account
─► statuses, timeline, exceptions (edi_exception) ─► ack reconciliation for inbound 997/999/TA1
host record change ─► ESB table event / record-change listener ─► edi_outbound_request (same tx)
─► outbound worker: map ─► guide validate ─► envelope (control numbers) ─► store raw
─► edi_outbound_message ─► transport send (AS2 / SFTP push / gateway OUTBOX / HTTPS / email)
─► MDN or delivery ─► expected ack due date ─► 997/999 reconcile or overdue alert
```
Package layout (`com.kingsrook.qbits.edi`):
| Package | Holds |
|---|---|
| (root) | `EdiQBitProducer`, `EdiQBitConfig`, `EdiRuntime` (the configured services) |
| `metadata` | the only scanned package: tables, PVS, joins, widgets, processes, app |
| `model` | table entities (`EdiPartner`, `EdiAgreement`, ...) |
| `x12` | StAEDI reading and writing, envelopes, separators, control numbers, splitting |
| `guide` | guide model, structural validator, rule engine, SNIP levels, built-in guide loader |
| `ack` | 997, 999 and TA1 builders, inbound ack reconciliation, overdue monitor |
| `map` | map model, path language, transforms, inbound apply, outbound build, sandbox preview |
| `flow` | inbox and outbound workers, state machines, reprocess and resend |
| `transport` | `EdiTransportPlugin` SPI and the plugins: `as2`, `sftp`, `gateway`, `https`, `email` |
| `storage` | `PayloadStore` (object storage with envelope encryption; database fallback) |
| `security` | secrets by environment-variable name, certificates, classification, service session |
| `hooks` | the `/qqq/hooks/edi/...` route provider |
| `uiapi` | spec'd `/qqq/v1/qbit/edi/...` extension (phase f) |
| `edifact` | EDIFACT envelopes and CONTRL (phase g) |
## 3. Platform and dependencies
| Dependency | Scope | Why |
|---|---|---|
| QQQ `4.1.0-RC.5` (then each new RC, G7) | compile | core, `QAlerts.raise/resolve` (RC.5) |
| `qqq-middleware-javalin` | optional | hooks route provider; UI API extension (RC.6) |
| `qqq-esb` | optional | outbound trigger from ESB table events |
| `qqq-backend-module-filesystem` (SFTP) | compile | SFTP push and VAN polling. This is core's SFTP client, the one qbit-sftp-data-integration uses; EDI uses it directly instead of depending on that qbit (G1). Remote SFTP only; nothing local. |
| `io.xlate:staedi` 1.26.x (Apache-2.0) | compile | streaming X12 and EDIFACT reader and writer with schema validation |
| `com.helger.as2:as2-lib` 5.1.x (Apache-2.0) | compile | AS2 send, receive, MDN, S/MIME via BouncyCastle |
| `qbit-object-storage` (its `api` package only) | optional | raw payload storage (section 10) |
| `qbit-file-gateway` (its `api` package only) | optional | inbound SFTP and partner-pull OUTBOX (section 5.3) |
Core features that arrive in RC.6, and how EDI bridges until then:
| Need | Core | Until RC.6 |
|---|---|---|
| `DataClassification` on fields (PHI) | `actions.ai.DataClassification` (#1142) | `EdiClassifications` stand-in, `STAND-IN(core=QRun-IO/qqq#1142, removeBy=4.1.0-RC.6)`, sets the classification reflectively when the class is present |
| User notifications | `QAlerts.notify` (#1125) | alerts only via `QAlerts.raise` (RC.5); notifications added with the RC.6 bump |
| UI API extension point | `MiddlewareSpecExtension` (#1128) | the sandbox is a record action (section 8.3); spec'd endpoints come in phase f |
The file-gateway `api` ships in that qbit's phase (a). Until it is on a published SNAPSHOT, EDI uses
`GatewayStandIn` with the identical shape, marked `STAND-IN(core=QRun-IO/qbit-file-gateway,
removeBy=qbit-file-gateway 0.1.0-RC.1)`.
## 4. Trading partners and agreements
### 4.1 Tables
All tables have `id`, `createDate`, `modifyDate` and, where people edit them, `modifiedBy`; audit is
on (field-level for configuration tables); record screens have sections. SQL table names equal QQQ
table names; fields are camelCase in QQQ and snake_case in SQL. Labels in brackets.
**`edi_partner`** [Trading Partner] (main, nav, Quick Search, ESB events)
- `name` (unique), `code` (short slug, unique), `status` (TESTING, ACTIVE, SUSPENDED, RETIRED),
`contactName`, `contactEmail`, `contactPhone`, `notes`.
- `defaultTimezone` (for date transforms), `containsPhi` (boolean; set for healthcare partners,
drives classification defaults and the home's PHI badge).
- Children: identities, agreements, transports, certificates, documents, exceptions.
**`edi_identity`** [EDI Identity] (child of partner; "ours" when `partnerId` is null)
- `partnerId` (null = one of our own identities), `standard` (X12, EDIFACT),
`interchangeQualifier` (ISA05/07, e.g. `ZZ`, `01`, `12`; EDIFACT UNB 0007),
`interchangeId` (ISA06/08, padded to 15 on write; UNB 0004/0010), `applicationCode` (GS02/03),
`description`, `isActive`.
- Unique (standard, interchangeQualifier, interchangeId, applicationCode). Inbound routing matches
on these.
**`edi_agreement`** [Agreement] (main, nav; child list on partner)
One agreement is one (our identity, partner identity, standard, usage) combination.
- `partnerId`, `localIdentityId`, `partnerIdentityId`, `standard` (X12, EDIFACT), `usageIndicator`
(T test, P production; ISA15). Test and production are separate agreements, so test traffic never
touches production records unless the agreement says so.
- `interchangeVersion` (ISA12: `00401`, `00501`), `elementSeparator` (default `*`),
`segmentTerminator` (default `~`), `componentSeparator` (ISA16, default `:`),
`repetitionSeparator` (ISA11 for 00501, default `^`), `lineEnding` (NONE, LF, CRLF; outbound).
Inbound separators are always read from the ISA itself; these settings are for outbound.
- `requestTa1` (ISA14), `sendTa1` (NEVER, ON_REQUEST, ON_ERROR, ALWAYS; default: on request and on
error).
- `inboundTransportId`, `outboundTransportId` (section 5).
- `controlNumberScope` (AGREEMENT, LOCAL_IDENTITY; default AGREEMENT; section 4.3).
- `duplicateWindowDays` (default 365), `onDuplicate` (REJECT_TA1, IGNORE, PROCESS; default
REJECT_TA1 with an exception row).
- `maxInterchangeBytes` (default 50 MB), `applyTestDocuments` (boolean, default false: T documents
are validated and acknowledged but not applied).
- `isActive`, `notes`.
**`edi_agreement_document`** [Agreement Document] (child of agreement)
One row per document, version and direction that the agreement carries.
- `agreementId`, `direction` (INBOUND, OUTBOUND), `transactionSet` (`850`, `837`, ...),
`functionalIdCode` (GS01: PO, PR, SH, IN, IB, PC, OW, SW, QM, SM, HC, HP, HS, HB, HR, HN, BE, FA),
`version` (GS08: `004010`, `005010`, `005010X222A1`, ...), `guideId` (a base or override guide),
`mapId`, `ackExpectation` (NONE, 997, 999), `ackDueMinutes` (default 1440), `ackLevel`
(GROUP_ONLY, ALL_TRANSACTIONS, ERRORS_ONLY; for the acks we send), `snipLevel` (1-7; section 11;
default 2 for non-HIPAA, 4 for HIPAA), `containsPhi` (defaulted from the version),
`targetTableName` (inbound) or `sourceTableName` (outbound), `outboundTrigger` and
`triggerFilter` (section 9.3), `batchMode` (IMMEDIATE, BATCH; outbound), `batchSchedule` (cron,
when BATCH), `isActive`.
- Unique (agreementId, direction, transactionSet, version).
**`edi_control_number`** [Control Number] (child of agreement)
- `agreementId` (or `localIdentityId` when the scope is LOCAL_IDENTITY), `level` (ISA, GS),
`lastValue`, `minValue` (1), `maxValue` (999999999), `wrapAllowed` (default true).
- ST02 is assigned per group (0001 upward), so it needs no row.
### 4.2 Inbound routing
1. Read the ISA: 106 fixed characters; the element separator is character 4, the repetition
separator ISA11 (00501), the component separator ISA16 and the segment terminator character 106.
2. Find the partner identity by (ISA05, ISA06) and our identity by (ISA07, ISA08), then the agreement
by both plus ISA15. No match: an `UNKNOWN_PARTNER` exception, and TA1 code `006` or `007` when
we can address a reply. The raw document is always kept.
3. Per GS, match GS02/GS03 when application codes are configured; per ST, find the agreement
document by direction INBOUND, ST01 and GS08 (exact, then by the base version `005010`). No
match: the group is rejected in the 997/999 (AK5/IK5 `R`, "transaction set not supported") and a
`NOT_CONFIGURED` exception is opened.
### 4.3 Control numbers
- Allocation is an atomic compare-and-set on `edi_control_number` (`UPDATE ... SET last_value = n+1
WHERE id = ? AND last_value = n`, retried) in its own short transaction, so concurrent replicas
never hand out the same number. Past `maxValue` it restarts at `minValue` when wrapping is
allowed; otherwise the outbound fails with a plain error.
- Numbers are allocated when an interchange is enveloped, not when it is sent, so a resend of the
same interchange keeps its numbers and the partner can detect the duplicate. "Resend As New"
re-envelopes with new numbers (section 9.5).
- Inbound duplicate detection per agreement, within `duplicateWindowDays`:
- interchange: the same (sender, receiver, ISA13, ISA15). TA1 note code `025`.
- group: the same GS06 from the same sender. AK9/IK9 `R`.
- transaction: the same ST02 within the group. AK5/IK5 `R`.
- Byte-identical payloads (the same sha256 within the window) are caught earlier, at the inbox,
and marked `DUPLICATE` without processing.
### 4.4 Acknowledgement expectations and overdue alerts
- Each outbound functional group whose agreement document expects a 997 or 999 gets `ackDueAt`
(sent time + `ackDueMinutes`).
- The overdue monitor (a scheduled process, every 5 minutes) calls `QAlerts.raise(AlertSignal)`
with alert type `edi.ack.overdue`, severity WARNING, dedup key `edi_functional_group:<id>`, a
record reference to the group and a link to the interchange. qbit-alerts delivers it (inbox,
channels, quiet hours); without qbit-alerts, core's logging sink records it.
- When the ack arrives, `QAlerts.resolve("edi.ack.overdue", dedupKey, note)`.
- A rejecting ack (AK9/IK9 `R`, or partial `P`) raises `edi.ack.rejected` (ERROR) and opens a
`REJECTED_ACK` exception.
- From RC.6, assignees of exceptions also get a `QAlerts.notify` notification.
## 5. Transports
### 5.1 Model
Transports are data. **`edi_transport`** [Transport] (main, nav):
- `name` (unique slug, used in hook URLs), `type`, `direction` (INBOUND, OUTBOUND, BOTH),
`partnerId` (null for a shared transport such as a VAN), `isActive`, `notes`.
- Typed settings in sections. Only the section for `type` is shown and validated (a pre-insert and
pre-update customizer calls the plugin's `validate`):
- **AS2:** `as2LocalId`, `as2PartnerId`, `as2Url`, `as2Subject`, `signAlgorithm` (SHA256, SHA384,
SHA512; SHA1 only when the partner requires it, flagged), `encryptAlgorithm` (AES128_CBC,
AES256_CBC, AES128_GCM, AES256_GCM; 3DES only when required, flagged), `compress`, `mdnMode`
(SYNC, ASYNC, NONE), `mdnSigned`, `localCertificateId`, `partnerCertificateId`,
`partnerTlsCertificateId` (optional pinning), `httpAuthUserEnvVar`, `httpAuthPasswordEnvVar`.
- **SFTP (push and poll):** `sftpHost`, `sftpPort`, `sftpUsername`, `sftpPasswordEnvVar` or
`sftpPrivateKeyEnvVar`, `sftpHostKeyFingerprint` (required; unknown host keys are refused),
`sftpOutboundPath`, `sftpInboundPath`, `sftpPollCron`, `sftpAfterPickup` (DELETE, MOVE),
`sftpArchivePath`, `sftpFilePattern`, `sftpTempSuffix` (upload as `.tmp`, then rename).
- **File gateway:** `gatewayEndpointName` (the `file_gateway_alias.name`: an INBOX endpoint for
inbound, an OUTBOX endpoint for partner-pull outbound).
- **HTTPS:** `httpsUrl`, `httpsAuthType` (NONE, BASIC, BEARER, HMAC, MTLS), `httpsSecretEnvVar`,
`httpsHeadersJson` (non-secret headers), `httpsInboundAuth` (for `/qqq/hooks/edi/https/{name}`:
BASIC, BEARER or HMAC, the secret by env var).
- **Email:** `emailProviderName` (qbit-email), `emailTo`, `emailFromAlias`,
`emailAttachmentName`.
- **VAN:** an AS2 or SFTP transport with `isVan` true and `vanName`. One VAN transport serves many
partners (`partnerId` null); routing is by ISA identities, never by transport.
- Retry policy: `maxAttempts` (default 8), backoff 1m, 5m, 15m, 1h, 3h, 6h, 12h, 24h.
**`edi_certificate`** [Certificate] (main, nav)
- `alias`, `partnerId` (null = ours), `usage` (SIGNING, ENCRYPTION, SIGNING_AND_ENCRYPTION, TLS),
`certificatePem` (the public certificate only), `subject`, `issuer`, `serialNumber`,
`fingerprintSha256`, `notBefore`, `notAfter` (all parsed from the PEM on save),
`privateKeyEnvVar` (ours only: the env var holding a PEM or base64 PKCS#12),
`privateKeyPasswordEnvVar`, `status` (ACTIVE, NEXT, RETIRED). `NEXT` supports rollover: inbound
accepts either key, outbound switches when "Promote Certificate" runs.
- Expiry: the monitor raises `edi.certificate.expiring` at 30, 14 and 7 days (dedup per
certificate and threshold), and `edi.certificate.expired` at expiry.
- Record actions: "Promote Certificate", "Export Public Certificate" (download the PEM to send to a
partner). Private keys never enter a table, a log or a response.
### 5.2 Plugin SPI
```java
public interface EdiTransportPlugin
{
String getType(); // "AS2", "SFTP", "GATEWAY", "HTTPS", "EMAIL"
void validate(EdiTransport transport, List<String> errors); // settings check on save
EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws EdiTransportException;
default void poll(EdiTransport transport, InboundSink sink) throws EdiTransportException { } // pull transports
default EdiTestResult test(EdiTransport transport) { ... } // the "Test Connection" record action
}
```
- Plugins register in `EdiTransportPluginsMetaData` (a `QSupplementalInstanceMetaData`). The
producer registers the built-in ones; a host can add its own (e.g. a VAN's proprietary API).
- `EdiSendResult`: SENT (with the transport message id), DELIVERED (sync MDN, or HTTP 2xx where the
transport confirms delivery), RETRY (with a reason), FAILED (permanent, with a reason).
- `InboundSink.receive(InboundPayload)` stores the bytes and inserts the inbox row (section 9.1).
Every inbound path, push or pull, ends in this one call.
- **Secrets:** settings name environment variables (`...EnvVar`). `EdiSecrets.resolve(name)` reads
them at use time; values are never stored, logged, cached beyond the call, or returned by any
API. Tests replace the resolver. A missing variable fails "Test Connection" and the send with
"environment variable X is not set" (the name only).
### 5.3 SFTP through qbit-file-gateway (inbound) and partner pull (outbound)
Settled with the qbit-file-gateway agent (its DESIGN.md section "Consumer and outbox API"):
- EDI registers `EdiFileGatewayConsumer` (name `edi`) with
`FileGatewayConsumersMetaData.ofOrWithNew(qInstance).withConsumer(new QCodeReference(...))`. An
INBOX endpoint (a `file_gateway_alias` row with `consumer_name = edi`) delivers each uploaded
file, at least once, after upload close and scan.
- `accept(InboundFile)` runs on the gateway worker as the SFTP account's user (channel `sftp`).
`InboundFile` carries `uploadJobId`, `endpointName`, `username`, `userId`, `remoteAddress`,
`path`, `fileName`, `sizeBytes`, `sha256`, `receivedAt`, `storageBucketAlias`, `storageKey` and
`openStream()` (always from object storage). EDI streams the bytes into its own encrypted payload
store, inserts an `edi_inbox` row keyed by (`GATEWAY`, `uploadJobId`) with the sha256, and returns
`FileAcceptance.accepted("edi_inbox:<id>")`; a storage failure returns `retryLater(reason,
duration)`. It never parses inside `accept()`.
- The inbox row records the endpoint, username, user id, file name and remote address. The partner
is still determined from the ISA. When the SFTP account is linked to a partner and the ISA sender
is a different partner, processing stops with a `PARTNER_MISMATCH` exception, so one partner
cannot send as another.
- Outbound to partners that pull from us: `FileGatewayOutbox.put(endpointName, fileName, stream,
sizeBytes)` writes to the partner's OUTBOX endpoint and returns an `OutboxFileRef`;
`onDownloaded(OutboxFileRef)` on our consumer marks the outbound message DELIVERED.
`FileGatewayOutbox.isAvailable()` false makes a GATEWAY transport fail validation with
"qbit-file-gateway is not installed".
### 5.4 SFTP push and VAN polling (remote servers)
For partners and VANs that host their own SFTP, EDI connects out with core's SFTP client
(`qqq-backend-module-filesystem`):
- **Push:** write `sftpOutboundPath/<fileName>.tmp`, then rename. File names follow a configurable
pattern, default `<partnerCode>_<transactionSet>_<isa13>_<yyyyMMddHHmmss>.edi`.
- **Poll:** a scheduled process per transport lists `sftpInboundPath` matching `sftpFilePattern`,
streams each file straight into `InboundSink` (no local file), and deletes or moves it only after
the inbox row commits. A crash between commit and delete re-reads the file on the next poll; the
inbox's sha256 dedupe absorbs it.
- qbit-sftp-data-integration stays the tool for generic file imports and report exports. EDI does
not depend on it (G1) but uses the same core client, so behaviour and host-key handling match.
### 5.5 AS2 (phase c)
Implemented by `As2Transport` (send, Test Connection) and `As2RouteProvider` (receive). All of it is
in memory; no keystore, key or temporary file is written, and as2-lib's file-backed pieces are not used.
- **Libraries.** as2-lib 5.1.8 supplies the algorithm tables (`ECryptoAlgorithmSign/Crypt`), the
`MIC` and `DispositionOptions` types and its BouncyCastle S/MIME signer. as2-lib's decrypt and
verify stage content through `AS2ResourceHelper` temporary files and its receive side needs
servlet classes, so decryption, signature verification, compression, encryption and the MIC
(RFC 4130, RFC 5751, RFC 5402) use BouncyCastle (`bcpkix`, `bcjmail`) directly in `As2Crypto`,
and MDNs are built and parsed in `As2Mdn`. The MIC is the digest of the exact bytes of the signed
MIME entity (headers included), or of the transmitted entity when unsigned, so any partner
transfer encoding (binary or base64) agrees.
- **Send:** the message is compressed (optional), signed, then encrypted; `Message-ID` is stable per
outbound message (`edi-<id>-<digest>@<as2LocalId>`) and recorded as `transportMessageId`, and the MIC
we computed is kept in `edi_outbound_message.transportMic`. Configuration problems (a missing key
variable, an expired certificate) are RETRY results that name only the problem. Sync MDN: verify the
signature (required when `mdnSigned`), compare Received-Content-MIC, and require disposition
`processed`; a mismatch or failed disposition is FAILED with the (sanitized) disposition text.
`NONE` leaves the message SENT. Async MDN: `Receipt-Delivery-Option` is
`{publicBaseUrl}/qqq/hooks/edi/as2/{transport}/mdn`; the message stays SENT until the MDN arrives,
then becomes DELIVERED or FAILED; an MDN not received within the agreement document `ackDueMinutes`
(60 when unset) raises the warning `edi.mdn.overdue`, resolved by the MDN.
- **Receive:** `POST /qqq/hooks/edi/as2/{transport}` (outside `/qqq/v1`). The handler checks
`AS2-From`/`AS2-To` against the transport (403 otherwise), reads the body up to the largest
`maxInterchangeBytes` of the agreements on the transport (50 MB default, in memory), decrypts with any
of our ACTIVE or NEXT keys, verifies the signature against the partner's ACTIVE and NEXT certificates
(signed is required when `signAlgorithm` is set, encrypted when `encryptAlgorithm` is set), unwraps
compression, computes the MIC, calls `InboundSink.receive` (`transportType` AS2, `externalId` =
Message-ID, scoped by transport id if another partner already used the same id), and answers with the
MDN: synchronous in the response (signed with our ACTIVE key when requested or `mdnSigned`), or, when
`Receipt-Delivery-Option` is present, 200 at once and a queued `edi_as2_mdn` row that
`As2MdnSender` (scheduled `edi_as2_mdn_sender`, every 15 seconds) posts with the send backoff and
`maxAttempts`, failing with `edi.send.failed`. An asynchronous receipt address must be HTTPS
(loopback excepted) and, unless signatures are required, on the partner's own host; failures before
the sender is authenticated are always answered synchronously.
- A retransmitted Message-ID returns the original MDN again, byte for byte, and is not reprocessed
(an asynchronous one is queued again). Failures return MDNs with `processed/error:
authentication-failed` (bad or unverifiable signature), `integrity-check-failed` (digest mismatch),
`decryption-failed`, `insufficient-message-security` or `unexpected-processing-error` (storage, or a
key we cannot load, so the partner retries); no inbox row exists for a rejected message.
- **Certificates:** `CertificateService` resolves our key from the `privateKeyEnvVar` variable (PEM
PKCS#8 or base64 PKCS#12 with `privateKeyPasswordEnvVar`), proves it matches the certificate, and
never caches or logs it. Outbound signs and encrypts with the ACTIVE certificate (the referenced one
when ACTIVE, otherwise the ACTIVE one of the same partner and usage), inbound accepts ACTIVE and NEXT.
"Promote Certificate" makes NEXT ACTIVE and retires the previous ACTIVE one of the same partner and
usage. A VAN transport (`isVan`, no `partnerId`) has its partner certificate on the VAN operator's
partner record, or with no partner for a shared certificate (no rollover candidates).
- MDNs, sent and received, are stored in **`edi_as2_mdn`** [AS2 MDN] (read-only; child list "AS2 MDNs"
of the outbound message and the inbox row; record label `<direction> MDN <originalMessageId>`):
`direction` (INBOUND = received, OUTBOUND = we sent it), `messageId`, `originalMessageId`,
`disposition`, `micAlgorithm`, `micValue`, `signed`, `signatureValid`, `payloadRef` (the raw MDN,
encrypted), `receivedAt`, `transportId`, `partnerId`, `outboundMessageId`, `inboxId`, and the
queue columns `deliveryUrl`, `deliveryStatus` (NONE, PENDING, SENT, FAILED), `deliveryAttempts`,
`nextAttemptAt`, `deliveredAt`.
- TLS: outbound uses the JVM trust store, or only the pinned certificate when `partnerTlsCertificateId`
is set. Inbound TLS terminates at the host's ingress. SHA1 and 3DES are accepted only when set
explicitly and set the read-only `weakCrypto` flag on the transport.
### 5.6 HTTPS and email
- **HTTPS out:** POST the interchange (`application/edi-x12` or `application/EDIFACT`) with the
configured auth. 2xx is DELIVERED. Responses are not parsed as acks (acks arrive as documents).
- **HTTPS in:** `POST /qqq/hooks/edi/https/{transport}` with BASIC, BEARER or HMAC (Standard
Webhooks signatures, verified with core's webhook signing utilities), a body size limit, then
`InboundSink.receive`.
- **CAQH CORE** (real-time 270/271 and 276/277 over SOAP or MIME) is a later plugin that reuses the
same parser and map engine with a synchronous reply. Not in v1.
- **Email out:** qbit-email's `EmailService.send` with the interchange as an attachment, category
TRANSACTIONAL, linked record `edi_outbound_message`. Healthcare documents may only go through a
provider marked `phi_allowed` and are refused otherwise. EDI calls qbit-email through its
published API only; until that API ships, this transport is disabled.
- **Email in:** through qbit-email's phase 2 inbound (an `InboundEmailHandler` registered by EDI).
Designed here, built when qbit-email inbound ships.
## 6. Parsing (X12 now, EDIFACT later)
- StAEDI (`EDIInputFactory`, `EDIStreamReader`) reads X12 as a stream, with no whole-document tree,
so a 50 MB 837 batch is processed in bounded memory per transaction set.
- One payload may hold several interchanges; each ISA/IEA becomes an `edi_interchange` row. Each
ST/SE is buffered as its own segment list (bounded by the guide's maximum segment count, default
100,000) for validation, ack building and mapping.
- Envelope checks: ISA fixed width, IEA01 = group count, IEA02 = ISA13, GE01 = transaction count,
GE02 = GS06, SE01 = segment count, SE02 = ST02. Envelope errors produce TA1 or AK9/IK9 codes.
- Character set: the X12 basic and extended character sets, and UTF-8 where the agreement allows it
(005010 extended). Unrecognised bytes are an envelope error, never silently replaced.
- Writing uses an in-memory serializer with the agreement's separators, fixed-width ISA
padding, component/repetition handling, and optional line endings.
- EDIFACT: StAEDI reads UNA/UNB/UNG/UNH and validates against EDIFACT schemas. It is designed into
the model now (`standard` columns, UNB fields on `edi_identity`, CONTRL as an ack type) and built
in phase g.
## 7. Implementation guides as data
### 7.1 Tables
**`edi_guide`** [Implementation Guide] (main, nav)
- `name`, `standard`, `version` (`004010`, `005010`, `005010X222A1`, ...), `transactionSet`,
`functionalIdCode`, `partnerId` (null = a base guide), `parentGuideId` (an override's base),
`revision` (increments on activation), `status` (DRAFT, ACTIVE, RETIRED), `source` (BUILT_IN,
IMPORTED, CUSTOM), `description`, `compiledChecksum`.
- BUILT_IN rows are read-only (a record lock keyed on `source`; only the loader writes them).
**`edi_guide_segment`** [Guide Segment] (child of guide): `guideId`, `sequence`, `loopId` (e.g.
`2000A`, `N1`, `HL`), `parentLoopId`, `loopMaxRepeat`, `segmentId`, `position` (the X12 position,
e.g. `0100`), `usage` (REQUIRED, SITUATIONAL, NOT_USED), `minUse`, `maxUse`, `name`,
`situationalRuleId`, `notes`.
**`edi_guide_element`** [Guide Element] (child of guide segment): `guideSegmentId`, `position` (3
for BEG03), `componentPosition` (null or 1..n), `elementId` (the data element number), `name`,
`dataType` (AN, ID, N0-N9, R, DT, TM, B), `minLength`, `maxLength`, `usage`, `codeListId`,
`fixedValue`, `repeatMax`, `situationalRuleId`.
**`edi_guide_rule`** [Guide Rule] (child of guide)
- X12 syntax notes per segment: `type` PAIRED (P), REQUIRED (R), EXCLUSION (E), CONDITIONAL (C) or
LIST_CONDITIONAL (L), with the element positions.
- Situational and inter-segment rules: `type` SITUATIONAL with an `expression` in the rule language
(section 7.3), `snipLevel` (2, 4, 6 or 7), `severity` (ERROR, WARNING), `message`, and the
997/999 error codes to report.
**`edi_code_list`** [Code List] (main, nav) and **`edi_code_list_value`** [Code] (child): `name`,
`elementId`, `source` (BUILT_IN, CUSTOM, EXTERNAL), `externalCodeSet` (for licensed code sets,
section 11.4); values have `code`, `description`, `isActive`.
### 7.2 Guide validation
StAEDI tokenizes the stream and checks envelope syntax. The guide engine applies the guide data
to each buffered transaction: segment order, nested loops, usage and repetition limits, element
types and lengths, code lists, syntax notes, and situational and inter-segment expressions. Both
JSON resources and database guide revisions use this same in-memory engine; guide validation does
not generate a second StAEDI schema or write files.
- Every finding becomes an `edi_transaction_error` row: segment position, segment id, element
position, the X12 code for the ack (AK3/AK4 for 997, IK3/IK4/CTX for 999), the SNIP level and a
plain message. Element values go in `valueExcerpt` only for non-PHI documents (section 12).
### 7.3 Rule language
A deliberately small expression language over the buffered transaction set, evaluated without a
scripting engine:
- paths as in maps (section 8.2): `CLM02`, `2300/CLM05-1`, `HL[HL03=22]/NM1[NM101=IL]/NM109`;
- functions: `present(p)`, `absent(p)`, `count(p)`, `sum(p)`, `eq(a, b)`, `in(p, 'A', 'B')`,
`matches(p, regex)`, `date(p)`, `and(...)`, `or(...)`, `not(x)`, `when(condition, then)`.
- Example: "if the patient is not the subscriber, the 2000C loop is required" is
`when(eq(2000B/SBR02, '18'), absent(2000C))` together with its converse.
### 7.4 Built-in guides and licensing
X12 standards and the HIPAA TR3s are copyrighted and licensed by ASC X12. A public Apache-2.0 repo
must not ship their text. So:
- **Built-in guides are authored in-house** for the documents in scope: segment and element
structure, usage, lengths and the code values the maps use, with our own short names and no copied
notes, descriptions or complete code lists. They are enough to parse, validate structurally,
acknowledge and map typical traffic, and are labelled "QQQ baseline guide".
- **Imported guides:** the table action "Import Guide" on `edi_guide` loads a guide that a licensee
exports from their licensed tools (a documented JSON format, plus an adapter for a common X12
schema JSON). Imported guides live in the host's database, never in this repo.
- Partner companion-guide differences are **override guides** (section 7.5).
- Built-in guides ship as JSON resources in the jar and are upserted by the loader at startup when
their checksum changes (as a new revision; the previous revision stays for documents processed
under it).
### 7.5 Partner overrides
- "Create Partner Override" (record action on a guide) copies the guide into a CUSTOM guide with
`partnerId` and `parentGuideId`, in DRAFT.
- Edits are ordinary record edits of its segments, elements, rules and code lists.
- "Activate Guide" requires that the guide compiles, increments `revision`, and retires the previous
ACTIVE revision for that partner. Documents record the guide id and revision they were validated
with, so reprocessing an old document can use the original or the current revision (a choice on
the reprocess action).
- "Compare With Base" lists the differences as standard Result lines (segments, elements and rules
added, removed or changed).
## 8. Maps
### 8.1 Tables
**`edi_map`** [Map] (main, nav)
- `name`, `direction` (INBOUND, OUTBOUND), `standard`, `version`, `transactionSet`, `guideId`,
`partnerId` (null = shared), `tableName` (the target or source QQQ table), `matchKeyFields`
(comma list; the inbound upsert key, e.g. `partnerId,poNumber`), `onMatch` (UPDATE, SKIP,
EXCEPTION), `businessKeyPath`, `businessKeyType`, `secondaryKeyPath`, `revision`, `status`
(DRAFT, ACTIVE, RETIRED), `parentMapId`, `description`.
**`edi_map_rule`** [Map Rule] (child of map): `sequence`, `ediPath`, `qqqPath` (`field`,
`association.field`), `transform`, `transformArgs`, `required`, `condition` (rule language),
`defaultValue`, `notes`.
**`edi_map_sample`** [Map Sample] (child of map): `name`, `payloadRef` (stored like any payload,
encrypted), `expectedResultJson`, `lastPreviewAt`, `lastPreviewStatus`. Synthetic data only; the
record screen says so, and samples on PHI maps are classified PHI.
### 8.2 Paths and transforms
- **EDI paths:** segment plus element position (`BEG03`), components with `-` (`CLM05-1`), loops by
id (`2300/CLM01`) or by qualifier predicate (`N1[N101=ST]/N3/N301`), repeats with an index or `*`
(`PO1[*]/PO102`). Inbound, `*` loops map to associations; outbound, associations drive loops.
- **QQQ paths:** fields and associations of the map's table, checked against the table's metadata
on activation.
- **Transforms:** `DATE` (CCYYMMDD; YYMMDD with a century window), `TIME` (HHMM, HHMMSS and
fractions), `DATETIME` (date + time + the partner's timezone), `DECIMAL` (implied decimals for N
types), `QUALIFIER` (pick by a sibling qualifier), `CODE` (translate through a code list or a
map-local table), `UNIT` (unit-of-measure conversion, EA, CA, PK, LB, KG, ..., with pack sizes
from a host field), `CONSTANT`, `CONCAT`, `SPLIT`, `SUBSTRING`, `TRIM`, `UPPER`, `LOOKUP` (find a
QQQ record id by a key, such as an item by UPC or a ship-to by N104, read as the service session),
`SEQUENCE` (outbound line numbers), `HL` (outbound HL numbering for 856 hierarchies). Hosts can
register their own transforms by name.
- A rule failure is a mapping error naming the rule, the EDI path and the QQQ path (with no value
for PHI documents).
### 8.3 Sandbox (phase a as an action, phase f as UI)
"Preview Map" (record action on `edi_map` and `edi_map_sample`): pick a sample or upload one (the
upload is stored as a sample), and the standard Review/Result lines show:
1. the parse: interchanges, groups and transaction sets found;
2. validation findings by SNIP level;
3. the 997/999 that would be sent;
4. the map result (records and associations that would be written), downloadable as JSON;
5. the diff against the previous map revision's result and against `expectedResultJson`.
Nothing is written to target tables: the preview runs the whole pipeline with a no-op applier.
Phase f adds spec'd endpoints (`POST /qqq/v1/qbit/edi/sandbox/preview` and related) for the mapper
UI.
### 8.4 No-code mapper UI (phase f; specified now, built later)
A custom Next UI page (custom UIs may be nav entries):
- left, the guide tree (loops, segments, elements with usage and code lists); right, the target
table's fields and associations; drag to create a rule, then choose a transform;
- a live preview pane on a chosen sample (the sandbox endpoint), with errors inline;
- revisions: edit a DRAFT, activate, compare revisions;
- reads and writes only through `/qqq/v1` (CRUD on `edi_map_rule` plus the extension endpoints).
## 9. Flows
### 9.1 Inbox and inbound processing
**`edi_inbox`** [Inbound Message] (child list on the transport and the interchange; linked from the
home):
- `transportId`, `transportType`, `externalId` (AS2 Message-ID, gateway `uploadJobId`, poll path and
modification time, HTTPS request id, email message id), `payloadRef`, `payloadSha256`,
`sizeBytes`, `fileName`, `remoteAddress`, `receivedAt`, `status` (PENDING, PROCESSING, PROCESSED,
RETRY_WAIT, DEAD_LETTER, DUPLICATE, IGNORED), `attempts`, `nextAttemptAt`, `claimedAt`,
`processedAt`, `lastError` (no payload content), `partnerId` (once known).
- Unique (`transportType`, `externalId`); sha256 duplicates within the window become DUPLICATE.
- The worker (a scheduled process, every 15 seconds, batches of 20) claims rows with a
compare-and-set on `status` and `claimedAt`. Claims older than 5 minutes are reclaimed, so any
replica can process and a crashed one loses nothing. Backoff 1m, 5m, 15m, 1h, 6h; then
DEAD_LETTER, an `edi_inbox_dead_letter` alert and an exception.
Per interchange, group and transaction set (each transaction set in its own database transaction,
so one bad document does not block the others):
1. envelope checks and TA1 (section 6);
2. partner and agreement routing (4.2), duplicate checks (4.3);
3. validation (7.2) at the agreement document's SNIP level;
4. ack building: one 997 or 999 per received group, sent per the agreement (9.4);
5. if the transaction set is accepted (`A` or `E`): map and apply (9.2); a rejected set is not
applied;
6. statuses, timeline events and exceptions.
### 9.2 Apply as a service account
- The applier runs `InsertAction` and `UpdateAction` (with associations) on the map's table under a
**service session** from `EdiQBitConfig.withServiceSessionSupplier(Supplier<QSession>)`, which the
host provides (a user with exactly the permissions EDI should have). Permission enforcement stays
on, so a map cannot write to a table the service account cannot. When partner locks are on,
that partner's security key is added to the session for the call.
- Upsert by `matchKeyFields`: query for an existing record as the service session; `onMatch`
decides. The 860 (PO change) is a map onto the same order table with UPDATE.
- The target table's customizers, validation, audit and ESB events all run, because this is the
normal action path.
- The target record id is stored on `edi_transaction` (`targetTableName`, `targetRecordId`). A host
can show a record's EDI documents with a child-list widget on `edi_transaction` filtered by
target.
- A failure rolls back that transaction set only, sets status EXCEPTION, and opens an `APPLY`
exception with the error.
### 9.3 Outbound
- **Trigger** (per outbound agreement document, `outboundTrigger`):
- `ESB_EVENT` (preferred when qqq-esb is configured): the source table publishes ESB table
events; an `EsbTrigger` subscribed to them runs the process `edi_outbound_enqueue`, which
applies `triggerFilter` (a QQueryFilter as JSON, e.g. `status = SHIPPED`) and inserts an
**`edi_outbound_request`** row (`agreementDocumentId`, `sourceTableName`, `sourceRecordId`,
`eventId`, `status`, `attempts`, `nextAttemptAt`, `claimedAt`, `lastError`). It is idempotent on
(event id, agreement document).
- `RECORD_CHANGE` (no broker): a core `RecordChangeListenerInterface` registered by EDI inserts the
same row inside the writer's transaction (a transactional outbox: the request exists exactly
when the source change commits).
- `MANUAL`: the table action "Send As EDI" on the source table, added when the host opts in, with
the standard Review/Result summary.
- **Outbound worker** (claims requests like the inbox): loads the source record as the service
session, maps it (section 8), and validates it against the guide (an invalid outbound document is
never sent; it opens a `VALIDATION` exception). It then batches per agreement (IMMEDIATE or on
`batchSchedule`), envelopes (control numbers, 4.3), stores the raw interchange and creates an
**`edi_outbound_message`**.
- **`edi_outbound_message`** [Outbound Message]: `interchangeId`, `transportId`, `payloadRef`,
`status` (QUEUED, SENDING, SENT, DELIVERED, RETRY_WAIT, FAILED, DEAD_LETTER, CANCELLED),
`attempts`, `nextAttemptAt`, `transportMessageId`, `sentAt`, `deliveredAt`, `lastError`. The
sender claims rows the same way and retries per the transport's policy; DEAD_LETTER raises
`edi.send.failed` and opens a `TRANSPORT` exception.
- **Reconcile:** an MDN or delivery confirmation sets DELIVERED; the expected 997/999 sets the
functional group's `ackStatus` (4.4); the home and the timeline show both.
### 9.4 Acknowledgements we send
- **TA1:** per the agreement's `sendTa1`, with codes from the envelope checks (e.g. `001` control
number mismatch, `006`/`007` invalid sender or receiver, `022` invalid control structure, `025`
duplicate).
- **997** (non-HIPAA, and 004010) and **999** (005010 HIPAA, `005010X231A1`): AK1, AK2, AK3, AK4,
AK5 and AK9, or IK3, IK4, IK5 and CTX, at the agreement document's `ackLevel`, built from the
validation findings.
- Acks are outbound interchanges on the same agreement and transport, with their own control
numbers, and appear as `edi_transaction` rows (997, 999, TA1) linked to what they acknowledge.
- Acks are never themselves acknowledged.
### 9.5 Resend, reprocess, search and timeline
Record actions, all with the standard Review/Result summary:
- **Reprocess** (on `edi_inbox`, `edi_interchange`, `edi_transaction`, `edi_exception`): run again
from the stored raw payload, from the parse or from the map step, with the original or the current
guide and map revisions. Idempotent: an applied document is matched by its target record and
updated or skipped per the map's `onMatch`. Acks are re-sent only when chosen.
- **Resend** (on `edi_outbound_message`, `edi_interchange`): send the same bytes again (the same
control numbers). **Resend As New**: re-envelope with new control numbers (for partners that
rejected a duplicate).
- **Cancel** (queued outbound), **Assign**, **Ignore** and **Mark Resolved** (exceptions).
- **Search by business keys:** `edi_transaction.businessKey` (PO, ASN, invoice, load and shipment
numbers, from the map's `businessKeyPath`, e.g. BEG03, BSN02, BIG02, B204, W0506),
`businessKeyType`, and `secondaryKey` (e.g. the PO on an 856 or 810). Healthcare keys (claim and
patient control numbers, member ids) go to `phiBusinessKey` instead (section 12). All are in Quick
Search; the PHI field matches only for users with FULL access to it (secured-data).
- **Timeline:** `edi_event` [Event] (child of interchange and transaction): `at`, `type` (RECEIVED,
STORED, PARSED, VALIDATED, ACK_SENT, MAPPED, APPLIED, ENVELOPED, SENT, MDN, DELIVERED,
ACK_RECEIVED, EXCEPTION, REPROCESSED, RESENT), `detail` (no payload values), `userName`. The
interchange and transaction screens show it in time order next to the record audit.
### 9.6 Runtime tables
**`edi_interchange`** [Interchange] (main, nav, Quick Search on ISA13, ESB events): `direction`,
`partnerId`, `agreementId`, `transportId`, `inboxId` or `outboundMessageId`, `standard`,
`senderQualifier`, `senderId`, `receiverQualifier`, `receiverId`, `controlNumber` (ISA13),
`interchangeVersion`, `usageIndicator`, `interchangeDate`, `groupCount`, `payloadRef`,
`payloadSha256`, `sizeBytes`, `status` (RECEIVED, ACCEPTED, ACCEPTED_WITH_ERRORS, REJECTED,
DUPLICATE, ENVELOPED, SENT, DELIVERED, FAILED), `ta1Status`, `containsPhi`.
**`edi_functional_group`** [Functional Group] (child of interchange): `functionalIdCode`,
`controlNumber` (GS06), `version` (GS08), `transactionCount`, `ackStatus` (A, E, P, R, NONE,
PENDING), `ackDueAt`, `ackReceivedAt`, `ackTransactionId`.
**`edi_transaction`** [Document] (main, nav, Quick Search, ESB events): `interchangeId`,
`functionalGroupId`, `direction`, `partnerId`, `agreementDocumentId`, `transactionSet`, `version`,
`controlNumber` (ST02), `businessKeyType`, `businessKey`, `secondaryKey`, `phiBusinessKey`,
`status` (RECEIVED, VALID, INVALID, MAPPED, APPLIED, EXCEPTION, QUEUED, SENT, ACKNOWLEDGED,
REJECTED, IGNORED), `ackStatus`, `errorCount`, `warningCount`, `guideId`, `guideRevision`, `mapId`,
`mapRevision`, `targetTableName`, `targetRecordId`, `segmentCount`, `containsPhi`.
**`edi_transaction_error`** [Validation Error] (child of transaction): `segmentPosition`,
`segmentId`, `loopId`, `elementPosition`, `componentPosition`, `snipLevel`, `ackCode`, `severity`,
`message`, `valueExcerpt` (non-PHI documents only).
**`edi_exception`** [Exception] (main, nav; the work queue): `type` (UNKNOWN_PARTNER,
PARTNER_MISMATCH, NOT_CONFIGURED, DUPLICATE, ENVELOPE, VALIDATION, MAPPING, APPLY, TRANSPORT,
REJECTED_ACK, OVERDUE_ACK, DEAD_LETTER), `status` (OPEN, IN_PROGRESS, RESOLVED, IGNORED),
`partnerId`, `inboxId`, `interchangeId`, `transactionId`, `outboundMessageId`, `summary`, `detail`
(no payload values), `assignedToUserId`, `resolvedBy`, `resolvedAt`, `resolution`, `occurrences`
(repeated failures of the same item increment it instead of adding rows).
The 824 Application Advice and the 864 are later document types on the same model (outbound
documents generated from an exception), not new tables.
## 10. Payload storage (database and S3 only)
- **`PayloadStore`** writes raw payloads (inbound files, outbound interchanges, AS2 messages and
MDNs, samples) to qbit-object-storage: the bucket alias from
`EdiQBitConfig.withPayloadBucketAlias` (default `edi`), key
`edi/<yyyy>/<MM>/<dd>/<kind>/<uuid>`, through `ObjectStorage.asSystem("edi", runId)`.
- **Envelope encryption, always:** each payload is encrypted with AES-256-GCM under a random data
key; the data key is wrapped with the master key from env var `QQQ_EDI_PAYLOAD_KEY` (base64, 32
bytes) and stored with its key id in the object's metadata. `QQQ_EDI_PAYLOAD_KEY_PREVIOUS` allows
rotation (read with either, write with the current one); the table action "Rewrap Payloads"
re-wraps old payloads. Bucket-side SSE is recommended as well, not relied on.
- `payloadRef` columns hold `objectstorage:<bucket>:<key>`; the plaintext sha256 is stored next to
it.
- **Database fallback:** without qbit-object-storage, payloads go to **`edi_payload`** (`kind`,
`ciphertext` BLOB, `keyId`, `sha256`, `sizeBytes`, `createDate`), encrypted the same way. It suits
the demo and tests; production should use object storage (startup logs a WARN, and the home
shows it).
- Nothing touches local disk: parsing streams from the store, AS2 uses memory and object storage,
and certificates come from table rows and env vars. A test asserts that a full round trip creates
no file under the JVM temp directory.
- **Retention** (`EdiQBitConfig.withRetention...`; the nightly table action "Purge Payloads"): raw
payloads are kept 7 years for healthcare (HIPAA documentation retention is 6 years) and 2 years
otherwise, configurable. Rows and their sha256 stay after the payload is purged. The home shows the
policy.
## 11. Validation levels (SNIP) and code sets
### 11.1 What each level checks and what we support
| SNIP | Meaning | Support |
|---|---|---|
| 1 | EDI syntax integrity: envelopes, segment and element syntax, separators | **Full** (StAEDI and the envelope checks) |
| 2 | HIPAA syntax requirements: TR3 usage (R/S/N), repeats, lengths, TR3 code values | **Full** for the rules in the active guide (the built-in baseline or an imported TR3) |
| 3 | Balancing: totals across segments (837 CLM02 equals the sum of the SV1/SV2 line charges; 835 BPR02 equals the sum of CLP04 less PLB adjustments; claim and line adjustments balance) | **Full** for 837P, 837I and 835 (coded checks) |
| 4 | Situational inter-segment rules ("if X then Y") | **Full** through guide rules (7.3); baseline guides carry the common ones, imported TR3s the rest |
| 5 | External code sets: ICD-10, CPT, HCPCS, NDC, CARC/RARC, taxonomy, ZIP | **Partial:** a pluggable `ExternalCodeSetProvider` (11.4); no licensed code set ships (CPT is AMA-licensed). Format checks (ICD-10 shape, NPI check digit, ZIP format) are built in. |
| 6 | Product type or line of business (ambulance, chiropractic, ...) | **Through override guides and rules**; no built-in content |
| 7 | Trading-partner-specific requirements (companion guides) | **Through partner override guides** (7.5) |
### 11.2 Findings and acks
SNIP 1-2 errors reject the transaction set (AK5/IK5 `R`) unless the agreement document downgrades a
rule to WARNING. SNIP 3-7 findings default to `E` (accepted with errors) plus an exception, because
business rejection belongs in an 824 or a 277CA, not in a 999. Each rule can set its severity.
### 11.3 Defaults
Non-HIPAA documents validate at level 2 by default and HIPAA documents at level 4. An agreement
document can raise or lower its level.
### 11.4 External code sets
`ExternalCodeSetProvider` (SPI: `boolean isValid(String codeSet, String code, LocalDate asOf)`),
registered by the host, for example backed by a licensed table the host loads. Code lists with
`source = EXTERNAL` delegate to it. Without a provider they check the format only, and the home says
so.
## 12. HIPAA and PHI
- **Classification** (`DataClassification`, core #1142; the stand-in until RC.6):
- PHI: `edi_transaction.phiBusinessKey`, `edi_transaction_error.valueExcerpt` (never written for
PHI documents, but classified anyway), `edi_map_sample.expectedResultJson`, `edi_inbox.fileName`
(file names often carry member or claim ids), and the payloads themselves (encrypted, never in a
table column).
- PII: partner contact fields. CONFIDENTIAL: everything else in the `edi_` tables.
- Host target tables classify their own fields. The map writes through normal actions, so their
classification applies to what it writes.
- With qbit-secured-data installed, PHI fields follow its tiers: a customer-service user sees
`phiBusinessKey` masked and can find a claim by exact match only (REVEALABLE), which is how claim
lookup is meant to work for non-FULL users. Without it, the fields are FULL to anyone with the
table permission, so a host carrying PHI should install it (the home warns).
- **No PHI outside the encrypted payloads and PHI-classified fields:** for documents with
`containsPhi`, errors, events, exceptions, alert texts, log lines and Result lines carry segment
and element positions and codes, never values. The 999 we send carries positions and element
references only.
- **Encryption at rest:** payloads always (section 10). PHI columns rely on the database's
encryption at rest (a host responsibility, stated in the README) plus secured-data masking.
- **Minimum necessary:** PHI maps write only the mapped fields. The raw document stays encrypted and
can be downloaded only with the record action "Download Raw Document" (permission
`edi.raw.download`), which is audited and, with secured-data, written to its access log.
- **Canary egress tests** (phase e, the core SECURED-DATA procedure): synthetic 837P and 835
documents carry `CANARY-PHI-<random>` in the patient name, member id, claim number and a
diagnosis. They run through receive, validate (with forced errors), 999, map, apply, exception,
reprocess, resend, the home widgets, exports of every `edi_` table, Quick Search, alerts and
captured logs, as a FULL, a MASKED and a HIDDEN user. The test fails on any substring of the canary
longer than 4 characters outside the encrypted payloads and the PHI-classified columns, and for the
MASKED and HIDDEN users anywhere at all.
- No real PHI anywhere in the repo: samples are synthetic, produced by a seeded generator.
## 13. App home and navigation
App `edi` [EDI], optionally under the host's parent app. Nav (main tables only):
- **Activity:** Documents (`edi_transaction`), Interchanges, Exceptions.
- **Setup:** Trading Partners, Agreements, Transports, Certificates, Implementation Guides, Code
Lists, Maps.
Sub-tables (identities, agreement documents, control numbers, guide segments, elements and rules,
code list values, map rules and samples, functional groups, validation errors, events, inbox,
outbound requests and messages, MDNs) are child lists on their parents.
**Home** (typed widgets only, no HTML; every figure links to the filtered table):
1. Statistics: documents in and out today and over 7 days, open exceptions, overdue acks, dead
letters, certificates expiring within 30 days.
2. Table: volume by partner and document type over 7 days (in, out, rejected), each cell linking to
`edi_transaction` filtered.
3. Table: overdue acknowledgements (partner, group, sent, due, age).
4. Table: recent errors (validation and transport, last 24 hours).
5. Child-record list: the exception queue (OPEN and IN_PROGRESS, oldest first), with Assign and
Reprocess actions.
6. Statistics: system state (payload store: object storage or the database fallback; secured-data
present; external code-set provider present; inbox backlog and the oldest pending age).
All counts run as the viewing user, so permissions and partner record locks apply. Real data only.
## 14. Security and permissions
- Table permissions use the full name: `edi.partner.read|insert|edit|delete`, and likewise for each
table (`edi.agreement.*`, `edi.transport.*`, `edi.certificate.*`, `edi.guide.*`, `edi.map.*`,
`edi.interchange.read`, `edi.transaction.read`, `edi.exception.read|edit`, ...). Runtime tables
are read-only to people (no insert or delete permission is offered; the qbit writes them as the
system).
- Process permissions: `edi.reprocess`, `edi.resend`, `edi.exception.assign`, `edi.guide.activate`,
`edi.guide.import`, `edi.map.activate`, `edi.map.preview`, `edi.certificate.promote`,
`edi.transport.test`, `edi.raw.download`, `edi.payload.purge`, `edi.admin`.
- **Partner record locks** (optional, `EdiQBitConfig.withPartnerLocks(true)`): every partner-scoped
table carries a READ_AND_WRITE lock on `partnerId` with the security key type `edi_partner` and the
all-access key `edi.admin`, so a host can limit operators to some partners. The service session
gets the needed partner key per call.
- Hooks authenticate per transport (AS2 signature and certificate, HTTPS auth), not with user
sessions; they run as the system only to store and enqueue.
- Client channels: the producer registers its own denies (architecture review F17): the assistant
channel is denied `edi_payload`, `edi_certificate` and `edi_inbox`, and the PHI fields follow
secured-data.
## 15. Demo
Phase b includes a test-scope `EdiDemoServer` with the Next UI and synthetic H2 seed;
see [DEMO.md](DEMO.md) for launch and acceptance details. The broader host demo below remains
a deployment example.
No kof22 adoption. The demo is a module in `qqq-sample-project` (in QRun-IO/qqq): a small orders,
shipments and invoices schema; a retailer partner over the file gateway or SFTP and a 3PL over AS2
against a local as2-lib peer; seeded synthetic documents; and a healthcare clearinghouse partner with
synthetic 837/835 for the PHI walkthrough. It runs on H2 with the database payload fallback, or with
MinIO for object storage.
## 16. Phases and acceptance
| Phase | Scope | Acceptance |
|---|---|---|
| a | Partners, identities, agreements, agreement documents, control numbers, transports (model), payload store, inbox and worker, X12 parse and envelopes, guide model, compiler and rule engine, built-in guides for 850 (004010, 005010) and the acks, 997/999/TA1 generation, inbound SFTP through the file gateway (stand-in until its api ships), exceptions, events, home v1, Preview Map | An 850 from a gateway upload becomes an applied record and a 997; TA1 and 997 for envelope and syntax errors; duplicates caught at every level; control numbers unique under 8 concurrent allocators; every table has a record screen; home counts are real; `./mvnw verify` green |
| b | Retail 850, 855, 856, 810, 846, 860 (004010, 005010) guides and maps; outbound (ESB and record-change triggers, batching, envelope, send); SFTP push and VAN polling; gateway OUTBOX; HTTPS in and out; inbound ack reconciliation; overdue-ack alerts; resend, reprocess, cancel | 855/856/810 built from host records, validated, enveloped and sent; a 997 reconciles and resolves the overdue alert, and a missing one raises it; resend keeps control numbers, resend-as-new does not |
| c | AS2 send and receive, sync and async MDN, certificates and rollover, expiry alerts, VAN over AS2 | Round trip against a local as2-lib peer: signed and encrypted, sync and async MDN, MIC verified, a bad signature refused, a retransmit gets the original MDN |
| d | Logistics 940, 945, 214, 204 guides and maps | Round trips per document |
| e | Healthcare 837P, 837I, 835, 270/271, 276/277, 834 (005010 TR3 baselines), 999, SNIP 3-4, PHI classification, canary egress tests; the email transport when qbit-email's API ships | 837 batches validated to SNIP 4 with balancing errors reported; correct 999s; canary test green |
| f | Sandbox endpoints and the mapper UI (UI API extension and a Next page) | Fragment committed and contract-tested; the mapper edits a map and previews a sample |
| g | EDIFACT (UNA/UNB/UNH, ORDERS, ORDRSP, DESADV, INVOIC, CONTRL) | EDIFACT round trips and CONTRL |
Each phase is one or more PRs into `develop`, with tests and, for UI-visible changes, screenshots of
the home and key records in light and dark at 390 and 1440 px.
### 16.1 Tests (all phases)
- Synthetic sample documents per type and version from a seeded generator; no real data.
- Round trips: parse, map and apply, then build the outbound document from the applied record and
compare.
- 997, 999 and TA1 generation against expected acks for crafted errors at every level.
- AS2 against a local as2-lib peer, in-process, in both directions.
- Control-number concurrency, wrap and duplicate detection.
- Partner overrides change validation outcomes as intended; map revisions and the sandbox diff.
- No local files during a full round trip; no secret values in logs or responses.
- Canary PHI egress (phase e).
## 17. Open questions (decided as recommended, 2026-10-12)
| # | Question | Recommendation (accepted) |
|---|---|---|
| Q1 | Ship full X12 or TR3 guide content? | No. In-house baseline guides plus an "Import Guide" action for licensed content (7.4). |
| Q2 | Use qbit-sftp-data-integration for SFTP push and polling? | No code dependency (G1); use core's SFTP client directly, the same one it uses (5.4). |
| Q3 | Where do raw documents live without object storage? | Encrypted in `edi_payload` in the database, for the demo and tests; production uses object storage (10). |
| Q4 | Outbound trigger: ESB or a record-change listener? | Both: the ESB trigger when a broker is configured, otherwise the in-transaction record-change listener; MANUAL for one-offs (9.3). |
| Q5 | Which user applies inbound documents? | A host-supplied service session with enforcement on (9.2). |
| Q6 | Test (ISA15 = T) documents? | Separate agreements; validated and acknowledged, applied only with `applyTestDocuments` (4.1). |
| Q7 | Business rejections (824, 277CA)? | Later document types on the same model; v1 uses exceptions plus `E` acks (11.2). |
| Q8 | Are PHI business keys searchable? | Yes, in a PHI-classified field, by exact match for REVEALABLE users through secured-data (12). |
| Q9 | Payload retention? | 7 years for healthcare, 2 years otherwise, configurable (10). |
| Q10 | SHA-1 and 3DES for AS2? | Only when a partner requires them, flagged on the transport and the home (5.1). |
| Q11 | Partner record locks? | Optional (`withPartnerLocks`), off by default (14). |
| Q12 | Demo host? | `qqq-sample-project`, not kof22 (15). |
## 18. Owner steps (outside code)
- Protect `develop` and make `test_only` required (the coordinator).
- For production use: set `QQQ_EDI_PAYLOAD_KEY`, an object-storage bucket alias `edi`, the service
user for the applier, AS2 keys and passwords as environment variables, and load licensed TR3
guides or code sets if healthcare validation beyond the baseline is needed.
## Phase a2 implementation notes (2026-10-13)
The inbound pipeline, map/apply service boundary, typed app/home, actions, gateway stand-in and
queued acknowledgements are implemented. These RC.5 adaptations refine the executable contract:
- Core alert metadata rejects underscore names: use `edi.inbox.dead-letter`, `edi.ack.overdue`
and `edi.ack.rejected`.
- Queued replies emit `ACK_QUEUED`; actual sending remains phase b.
- `inboundPosition` on interchange/group/transaction distinguishes repeated controls within
a payload and makes retries resume the same occurrence.
- Reprocessing a descendant restarts its containing inbox using original/current definitions,
stable checkpoints and idempotent reply queuing; live claims cannot be reset.
- Targets and associations must share the EDI RDBMS backend for atomic ST application.
Cross-backend application fails as APPLY.
- RC.5 binds UTC LocalDateTime. DATE_TIME DDL uses zone-less UTC TIMESTAMP to prevent
host-timezone shifts in leases and due times. Existing timestamp-with-time-zone columns
require migration preserving UTC instant values.
- Typed TABLE supports a table-level link rather than individual count-cell URLs. Volume/queue
tables use filtered-table links; STATISTICS links each figure. No HTML widgets are used.
- `edi_preview_download` is a storage-only metadata alias bridging standard streams to encrypted
PayloadStore. It has no SQL table. Hosts configure it as the Javalin upload archive and avoid
multipart disk staging. Samples are synthetic only; downloads require session-registered refs.
- Preview compares structured before/after JSON against expected output and the previous revision.
PHI map results and acknowledgement values are suppressed. Selection/upload use the standard Review.
- [MAPS.md](MAPS.md) defines transform arguments. QUALIFIER selects via path predicates; CODE uses
map-local translations; UNIT uses configured factors/pack sizes; DATETIME uses an explicit timezone.
Host code-list/pack-field conversions use registered transforms. SEQUENCE/HL remain phase b.
- Both payload adapters buffer complete envelopes in memory and use no runtime filesystem storage.
Object storage uses only its published API.
- Standard optional ESB publication follows RC.5. Document events use the core envelope/publisher
with ID/status only because the standard factory copies every field, including PHI keys.
- The a1 classification stand-in and host responsibility for PHI field protection remain unchanged.
Synthetic H2 verification covers permissions, association rollback, retry/stale claims, gateway
storage failures, original/current replay, 997/999, ack reconciliation, previews and home renderers.
The object-storage facade is final with restricted constructors: key/encryption metadata are tested
without provider internals. Live S3, deployed gateway/scheduler and light/dark browser layout remain
deployment validation. Outbound messages intentionally stay QUEUED.
## Phase b implementation notes (2026-10-13)
RC.5 adaptations implemented in this worktree:
- Record-change listeners are registered in instance metadata and receive the source writer's transaction. Successful enqueues participate in that transaction (rollback is tested). RC.5 catches listener exceptions, so a failed enqueue cannot veto a source write; hosts requiring strict delivery guarantees must use the explicit enqueue boundary in their transaction.
- RC.5 ESB consumption requires JMS. `edi_outbound_enqueue` accepts `sourceTableName`, `sourceRecordId`, and `eventId`; a host EsbTrigger can invoke that process. Tests exercise that boundary without a broker.
- The filesystem module supplies Apache MINA SSHD. SFTP uses its streaming client directly with mandatory pinned host-key verification, remote temporary upload/rename, and durable inbox receipt before remote cleanup. No runtime local files are created.
- HTTPS uses core `WebhookSigner`/`WebhookVerifier` for Standard Webhooks HMAC. The optional Javalin provider exposes the non-UI `/qqq/hooks/edi/https/{transport}` endpoint with a 50 MiB limit.
- Worker claims use database row locks within the processing transaction. A crashed worker rolls back its claim; remote-send ambiguity remains at-least-once. Batch schedules are installed from active agreement documents during runtime initialization, with up to 100 transactions from one agreement document per envelope. Schedule changes require host scheduler refresh/restart. SFTP poll schedules are installed the same way.
- Acknowledgement deadlines start on the first successful send. Resend reuses the archived bytes; Resend As New re-envelopes the archive with new ISA/GS controls and linked tracking snapshots, without remapping changed source records.
- The test-scope demo uses `QApplicationJavalinServer`, Next UI 1.0.0-RC.14, fresh in-memory H2, anonymous demo authentication, synthetic certificates generated in memory, and an in-memory gateway OUTBOX provider. See `DEMO.md`. A host scheduler and production transport credentials remain host responsibilities.
## Phase d implementation notes (2026-10-14)
- Guides for 940, 945, 214 and 204 are in-house baselines in both releases, with the same structure in 004010 and 005010 (as in phase b). Code lists carry only the values the sample maps and tests use; ID elements without a list are unconstrained. Control totals (940 W7601 against the W0101 lines, 945 W0301 against the W1203 lines) and the 204 stop rules (first stop LD, at least one UL) are guide-level `SITUATIONAL` rules, because the rule evaluator reads whole-document values; per-segment relationships use syntax notes (AT7 status/reason and date/time pairing, W12 difference needs ordered and shipped) and the MS1 segment `condition`, which is evaluated per LX occurrence.
- The 204 stop-level N1 loop has its own loop id (`S5N1`, parent `S5`) so it does not merge with the header N1 loop; map paths name it (`S5[*]/S5N1/N102`).
- Mapping needed two small engine additions, documented in `MAPS.md`: inbound paths may join elements with `+` so `DATETIME` receives a date and a time from separate elements, and outbound `DATEPART`/`TIMEPART` split a QQQ timestamp. Optional inbound DECIMAL/DATETIME rules use a `condition` because a transform with no input still fails the rule (unchanged phase a behavior).
- `InboundApplier` assumed every target table has associations; a flat table (the 214 status sample) hit a null pointer. It is now null-safe.
- The 214 sample map records the first LX event per transaction into a flat status table and always inserts (no match key), so repeated statuses form a history; a host that wants every LX event maps an association.
- Demo: Harbor Logistics gets a gateway OUTBOX transport for 945s (the partner pulls over SFTP, per 5.3) after the retail seed has run, so its earlier acknowledgements keep their visible retries; the fourth partner Ridgeline Freight is added, so `EdiDemoServerTest` now expects four partners.
||||||| parent of bfb63ce (feat: phase c - AS2 transport)
## Phase c implementation notes (2026-10-14)
- as2-lib's decrypt and verify write temporary files and its receive helpers need servlet classes, so only
its enum tables, `MIC`, `DispositionOptions` and signer are used; the rest is BouncyCastle in memory
(section 5.5). Its dependency tree pulls `bcjmail` 1.85 (Jakarta Mail), so `bcpkix` is pinned to 1.85 and
promoted from test scope; no other QQQ dependency changed.
- The MIC is computed over the exact signed bytes (headers included) instead of as2-lib's
decode-then-re-encode, which differs for base64 content; verified against an independent
BouncyCastle peer with base64 and binary transfer encodings.
- Encryption wraps the exact bytes that were hashed (CMS enveloped data over the serialized signed
entity), so the MIC cannot drift between signing and encryption.
- `edi_as2_mdn` doubles as the asynchronous MDN queue (`deliveryStatus`, `deliveryAttempts`,
`nextAttemptAt`) instead of a separate table; the scheduled step is `edi_as2_mdn_sender`.
- New columns: `edi_outbound_message.transport_mic` (the MIC we sent, for asynchronous comparison) and
`edi_transport.weak_crypto`. New config: `EdiQBitConfig.publicBaseUrl`.
- The alert is `edi.mdn.overdue` (RC.5 rejects underscores); `SendWorker` now records the transport
failure reason (value-free by contract: an HTTP status, an AS2 disposition or a variable name) in
`lastError` instead of a generic text.
- Not implemented: EC and RSA-PSS signing keys (RSA only), MIC comparison under a different algorithm than
the one requested, CAQH/Peppol profiles, and AS2 over a host-mounted client certificate (`MTLS`).
-51
View File
@@ -1,51 +0,0 @@
# EDI guide JSON
The five files in `src/main/resources/edi/guides/` are original QQQ baseline guides, not licensed
X12/TR3 documentation or complete code sets. JSON loads into `guide.Guide` with
`BuiltInGuideLoader.read(InputStream)`. `GuideRepository.load(id)` reconstructs the same model from
the normalized `edi_guide*` and code-list tables.
The root has `name`, `standard`, `version`, `transactionSet`, `functionalIdCode`, ordered
`segments`, and optional `rules`. A segment has:
- `segmentId`, `usage` (`REQUIRED`, `SITUATIONAL`, `NOT_USED`), `minUse`, `maxUse`;
- optional `loopId`, `parentLoopId`, and `loopMaxRepeat`; the first segment starts each loop
occurrence, with parents declared before children;
- optional boolean `condition`, `elements`, and `rules`.
An element has one-based `position` and optional `componentPosition`, `elementId`, `dataType`
(`AN`, `ID`, `N0`–`N9`, `R`, `DT`, `TM`, `B`), `minLength`, `maxLength`, `usage`, `fixedValue`,
`repeatMax`, and `condition`. `codes` is an optional array of permitted ID values. Omitted/null
means no code constraint; an explicitly empty array permits no values. DT accepts YYMMDD with
a 1950–2049 century window or CCYYMMDD; TM accepts HHMM, HHMMSS, and one/two fractional digits.
N types accept integer lexical values with an implied decimal point.
Rules use `type` (`PAIRED`, `REQUIRED`, `EXCLUSION`, `CONDITIONAL`, `LIST_CONDITIONAL`, or
`SITUATIONAL`), `positions` for syntax notes, or `expression` for situational rules. Optional
`snipLevel`, `severity`, `message`, `ak3Code`, `ak4Code`, `ik3Code`, and `ik4Code` customize findings.
Syntax-note positions are one-based elements. Segment/element `condition` means “required when
this expression is true.” Explicit `when(...)` rules express prohibitions and other relationships.
Paths include `BEG03`, `REF01-2`, `N1/N101`, `N1[N101=ST]/N301`, `PO1[*]/PO102`, and
`N1[2]/N101`. Indexes start at one; unindexed paths select all occurrences. Loop scopes come from
the guide, so a qualified parent selects its child segments. Functions are `present`, `absent`,
`count`, `sum`, `eq`, `in`, `matches`, `date`, `and`, `or`, `not`, and `when`. String literals use
single quotes; numeric literals use decimal notation. `date` tests calendar validity. Examples:
```text
when(eq(N101, 'ST'), present(N3))
eq(sum(PO102), 12)
and(in(BEG01, '00', '01'), date(BEG05))
```
No script engine runs. PHI validation suppresses all excerpts and custom rule messages. Functional
acks omit submitted values; situational 999 CTX records carry only trigger positions, following
[X12's CTX interpretation](https://x12.org/resources/requests-for-interpretation/rfi-1311-999ctx-context).
At host startup, after schema creation and service-session initialization, call
`EdiRuntime.current().initialize()`. Unchanged resource checksums are no-ops. A changed resource
creates a new revision and retires the former active built-in revision in one database transaction;
historical rows and their children remain addressable by id. A reverted resource also creates a
new revision. Built-in guide writes require the loader's security key (`edi.guide.loader`), or a
system service session. Configuration editors can receive `CUSTOM`/`IMPORTED` values of the
`edi_guide_source` key without receiving permission to edit `BUILT_IN` rows.
-78
View File
@@ -1,78 +0,0 @@
# EDI maps
A map comprises an `edi_map` row and ordered `edi_map_rule` rows. EDI paths resolve through the
guide's loop tree; target paths support one association level. `condition`, `defaultValue` and
`required` apply within each loop occurrence. Missing optional values preserve child alignment.
`transformArgs` is a JSON object with string values. Omitted transforms copy the first value.
An inbound `ediPath` may join several element paths with `+` (outside any `[...]` predicate); their
values are concatenated in order. This is how a date and a time held in separate elements reach
`DATETIME`: `G62[G6201=10]/G6202+G62[G6201=10]/G6204`. Inside a `[*]` loop path only the part after
the anchor is split (`S5[*]/G6202+G6204`). A `condition` such as `present(G62[G6201=10]/G6202)` skips
the rule when the elements are absent; a transform with no input otherwise fails the rule.
| Transform | Arguments / result |
| --- | --- |
| DATE | CCYYMMDD or YYMMDD; fixed 1950–2049 century window; QQQ date |
| TIME | HHMM, HHMMSS, fractional seconds; normalized string |
| DATETIME | Date and time values, or `time` argument; `timezone` defaults UTC |
| DATEPART / TIMEPART | Outbound halves of a DATETIME: a QQQ timestamp (an instant, an offset timestamp, or a zone-less one read as UTC) rendered as CCYYMMDD or HHMMSS in the `timezone` argument (default UTC) |
| DECIMAL | `scale` implied decimal places, default 0 |
| QUALIFIER | Sibling predicate in `ediPath`, e.g. `N1[N101=ST]/N104` |
| CODE | Map-local translation object, e.g. `{"EA":"each","CA":"case"}` |
| UNIT | `factor`, `divisor`, `scale` (defaults 1, 1, 6), HALF_UP |
| CONSTANT | `value`; needs no EDI input |
| CONCAT | `separator`, default empty; joins selected values |
| SPLIT | Literal `separator` (comma), zero-based `index` (0) |
| SUBSTRING | Zero-based `start` inclusive, `end` exclusive |
| TRIM / UPPER | Whitespace trim / locale-independent uppercase |
| LOOKUP | `table`, `key`, `result` (default id); exactly one permitted row |
| SEQUENCE | Outbound loop occurrence, starting at `start` (default 1) |
| HL | Outbound HL01 counter, HL02 nearest parent, HL03 `code`, HL04 child indicator |
Supply the partner timezone explicitly for DATETIME. UNIT uses configured conversion factors/pack
sizes; host-dependent field lookups and code-list translation can register named transforms through
`EdiRuntime.current().transforms().register(name, transform)`. Transforms execute as the service
session. LOOKUP explicitly enforces permissions.
Samples reference encrypted PayloadStore objects. Preview JSON contains `values` and recursive
`associations`; `expectedResultJson` uses the same shape. Comparisons show structured before/after
JSON as Result lines. Preview never invokes target application. See the synthetic 850 resources
under `src/main/resources/edi/maps` and [GUIDE-FORMAT.md](GUIDE-FORMAT.md).
## Outbound maps
OUTBOUND rules are evaluated in sequence order. EDI path prefixes create nested loop scopes;
the directly mapped QQQ association drives each occurrence. Nested QQQ paths such as
`cartons.items.sku` are supported and fetched recursively by the worker. Constants within a
loop can carry an association field as `qqqPath` to identify their scope. See the 856 samples
for Shipment/Order/Pack/Item loops and parent numbering.
DATE/TIME format QQQ values as X12 dates/times; DECIMAL multiplies by the implied `scale`.
Other registered transforms operate on the source value. Conditions use the existing EDI rule
language over segments already emitted. A missing required mapping input fails mapping;
structurally invalid mapped output opens VALIDATION and is never sent. ST/SE are generated by
the mapper and group-local controls are assigned by the worker. Component-target paths are
currently rejected explicitly; the bundled retail maps use simple elements.
## Warehouse and logistics samples
Phase d adds baseline guides and sample maps for four documents; test sources define the synthetic
`sample_wms_*`, `sample_shipment_status` and `sample_load*` tables. Maps with a second direction use
an `-in` or `-out` file suffix.
| File | Direction | Target | Business key |
| --- | --- | --- | --- |
| `940-*-sample.json` | INBOUND | `sample_wms_order` + `lines` | W0502 (secondary W0503) |
| `945-*-sample.json` / `945-in-*` | OUTBOUND / INBOUND | `sample_wms_shipment` + `lines` | W0602 |
| `214-*-sample.json` | INBOUND | `sample_shipment_status` (flat, first LX event) | B1002 (secondary B1001) |
| `204-*-sample.json` / `204-out-*` | INBOUND / OUTBOUND | `sample_load` + `stops` | B204 |
Order dates and appointments arrive as `DATETIME` from a qualified G62 (`G62[G6201=10]/...`) or from
the stop's G62 and leave as `DATEPART` plus `TIMEPART`. The 204 stop address is read from the nested
loop (`S5[*]/S5N1/N102`; the guide gives the stop-level N1 loop its own id, `S5N1`, so it does not
collide with the header N1 loop) and the stop sequence is mapped from S501. A 214 can carry several LX
events; the sample map records the first (`LX[1]/...`) and a host that wants all of them maps an
association instead. Optional outbound qualifiers such as MAN01 and L1102 are constants, so the
sample source rows must carry the value they qualify (the carton SSCC, the bill of lading).
-567
View File
@@ -1,567 +0,0 @@
CREATE TABLE edi_agreement (
apply_test_documents BOOLEAN,
component_separator VARCHAR(255),
control_number_scope VARCHAR(255),
create_date TIMESTAMP,
duplicate_window_days INTEGER,
element_separator VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbound_transport_id INTEGER,
interchange_version VARCHAR(255),
is_active BOOLEAN,
line_ending VARCHAR(255),
local_identity_id INTEGER,
max_interchange_bytes BIGINT,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
notes VARCHAR(10000),
on_duplicate VARCHAR(255),
outbound_transport_id INTEGER,
partner_id INTEGER,
partner_identity_id INTEGER,
repetition_separator VARCHAR(255),
request_ta1 BOOLEAN,
segment_terminator VARCHAR(255),
send_ta1 VARCHAR(255),
standard VARCHAR(255),
usage_indicator VARCHAR(255),
UNIQUE (local_identity_id, partner_identity_id, standard, usage_indicator)
);
CREATE TABLE edi_agreement_document (
ack_due_minutes INTEGER,
ack_expectation VARCHAR(255),
ack_level VARCHAR(255),
agreement_id INTEGER,
batch_mode VARCHAR(255),
batch_schedule VARCHAR(255),
contains_phi BOOLEAN,
create_date TIMESTAMP,
direction VARCHAR(255),
functional_id_code VARCHAR(255),
guide_id INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
is_active BOOLEAN,
map_id INTEGER,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
outbound_trigger VARCHAR(255),
snip_level INTEGER,
source_table_name VARCHAR(255),
target_table_name VARCHAR(255),
transaction_set VARCHAR(255),
trigger_filter VARCHAR(10000),
version VARCHAR(255),
UNIQUE (agreement_id, direction, transaction_set, version)
);
CREATE TABLE edi_as2_mdn (
create_date TIMESTAMP,
delivered_at TIMESTAMP,
delivery_attempts INTEGER,
delivery_status VARCHAR(20),
delivery_url VARCHAR(1000),
direction VARCHAR(20),
disposition VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbox_id INTEGER,
message_id VARCHAR(255),
mic_algorithm VARCHAR(50),
mic_value VARCHAR(255),
modify_date TIMESTAMP,
next_attempt_at TIMESTAMP,
original_message_id VARCHAR(255),
outbound_message_id INTEGER,
partner_id INTEGER,
payload_ref VARCHAR(255),
received_at TIMESTAMP,
signature_valid BOOLEAN,
signed BOOLEAN,
transport_id INTEGER
);
CREATE TABLE edi_certificate (
alias VARCHAR(255),
certificate_pem VARCHAR(10000),
create_date TIMESTAMP,
fingerprint_sha256 VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
issuer VARCHAR(255),
modified_by VARCHAR(255),
modify_date TIMESTAMP,
not_after TIMESTAMP,
not_before TIMESTAMP,
partner_id INTEGER,
private_key_env_var VARCHAR(255),
private_key_password_env_var VARCHAR(255),
serial_number VARCHAR(255),
status VARCHAR(20),
subject VARCHAR(255),
usage VARCHAR(255)
);
CREATE TABLE edi_code_list (
create_date TIMESTAMP,
element_id VARCHAR(255),
external_code_set VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
source VARCHAR(255)
);
CREATE TABLE edi_code_list_value (
code VARCHAR(255),
code_list_id INTEGER,
create_date TIMESTAMP,
description VARCHAR(10000),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
is_active BOOLEAN,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
UNIQUE (code_list_id, code)
);
CREATE TABLE edi_control_number (
agreement_id INTEGER,
create_date TIMESTAMP,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
last_value BIGINT,
level VARCHAR(255),
local_identity_id INTEGER,
max_value BIGINT,
min_value BIGINT,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
scope_key VARCHAR(255),
wrap_allowed BOOLEAN,
UNIQUE (scope_key, level)
);
CREATE TABLE edi_event (
at TIMESTAMP,
create_date TIMESTAMP,
detail VARCHAR(10000),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbox_id INTEGER,
interchange_id INTEGER,
modify_date TIMESTAMP,
transaction_id INTEGER,
type VARCHAR(255),
user_name VARCHAR(255)
);
CREATE TABLE edi_exception (
assigned_to_user_id VARCHAR(255),
create_date TIMESTAMP,
detail VARCHAR(10000),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbox_id INTEGER,
interchange_id INTEGER,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
occurrences INTEGER,
outbound_message_id INTEGER,
partner_id INTEGER,
resolution VARCHAR(10000),
resolved_at TIMESTAMP,
resolved_by VARCHAR(255),
status VARCHAR(255),
summary VARCHAR(255),
transaction_id INTEGER,
type VARCHAR(255)
);
CREATE TABLE edi_functional_group (
ack_due_at TIMESTAMP,
ack_received_at TIMESTAMP,
ack_status VARCHAR(255),
ack_transaction_id INTEGER,
control_number VARCHAR(255),
create_date TIMESTAMP,
functional_id_code VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbound_position INTEGER,
interchange_id INTEGER,
modify_date TIMESTAMP,
sender_id VARCHAR(255),
transaction_count INTEGER,
version VARCHAR(255)
);
CREATE TABLE edi_guide (
compiled_checksum VARCHAR(255),
create_date TIMESTAMP,
description VARCHAR(10000),
functional_id_code VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
parent_guide_id INTEGER,
partner_id INTEGER,
revision INTEGER,
source VARCHAR(255),
standard VARCHAR(255),
status VARCHAR(255),
transaction_set VARCHAR(255),
version VARCHAR(255),
UNIQUE (name, revision)
);
CREATE TABLE edi_guide_element (
code_list_id INTEGER,
component_position INTEGER,
create_date TIMESTAMP,
data_type VARCHAR(255),
element_id VARCHAR(255),
fixed_value VARCHAR(255),
guide_segment_id INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
max_length INTEGER,
min_length INTEGER,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
position INTEGER,
repeat_max INTEGER,
situational_rule_id INTEGER,
usage VARCHAR(255)
);
CREATE TABLE edi_guide_rule (
ak3_code VARCHAR(255),
ak4_code VARCHAR(255),
create_date TIMESTAMP,
expression VARCHAR(10000),
guide_id INTEGER,
guide_segment_id INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
ik3_code VARCHAR(255),
ik4_code VARCHAR(255),
message VARCHAR(10000),
modified_by VARCHAR(255),
modify_date TIMESTAMP,
positions VARCHAR(255),
severity VARCHAR(255),
snip_level INTEGER,
type VARCHAR(255)
);
CREATE TABLE edi_guide_segment (
create_date TIMESTAMP,
guide_id INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
loop_id VARCHAR(255),
loop_max_repeat INTEGER,
max_use INTEGER,
min_use INTEGER,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
notes VARCHAR(10000),
parent_loop_id VARCHAR(255),
position VARCHAR(255),
segment_id VARCHAR(255),
sequence INTEGER,
situational_rule_id INTEGER,
usage VARCHAR(255)
);
CREATE TABLE edi_identity (
application_code VARCHAR(255),
create_date TIMESTAMP,
description VARCHAR(10000),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
interchange_id VARCHAR(255),
interchange_qualifier VARCHAR(255),
is_active BOOLEAN,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
partner_id INTEGER,
standard VARCHAR(255),
UNIQUE (standard, interchange_qualifier, interchange_id, application_code)
);
CREATE TABLE edi_inbox (
attempts INTEGER,
claimed_at TIMESTAMP,
create_date TIMESTAMP,
external_id VARCHAR(255),
file_name VARCHAR(255),
gateway_user_id VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
last_error VARCHAR(255),
modify_date TIMESTAMP,
next_attempt_at TIMESTAMP,
partner_id INTEGER,
payload_ref VARCHAR(255),
payload_sha256 VARCHAR(255),
processed_at TIMESTAMP,
received_at TIMESTAMP,
remote_address VARCHAR(255),
reprocess BOOLEAN,
size_bytes BIGINT,
status VARCHAR(255),
transport_id INTEGER,
transport_type VARCHAR(255),
use_current_revisions BOOLEAN,
username VARCHAR(255),
UNIQUE (transport_type, external_id)
);
CREATE TABLE edi_interchange (
agreement_id INTEGER,
contains_phi BOOLEAN,
control_number VARCHAR(255),
create_date TIMESTAMP,
direction VARCHAR(255),
group_count INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbound_position INTEGER,
inbox_id INTEGER,
interchange_date TIMESTAMP,
interchange_version VARCHAR(255),
modify_date TIMESTAMP,
original_interchange_id INTEGER,
outbound_message_id INTEGER,
partner_id INTEGER,
payload_ref VARCHAR(255),
payload_sha256 VARCHAR(255),
receiver_id VARCHAR(255),
receiver_qualifier VARCHAR(255),
sender_id VARCHAR(255),
sender_qualifier VARCHAR(255),
size_bytes BIGINT,
standard VARCHAR(255),
status VARCHAR(255),
ta1_status VARCHAR(255),
transport_id INTEGER,
usage_indicator VARCHAR(255)
);
CREATE TABLE edi_map (
business_key_path VARCHAR(255),
business_key_type VARCHAR(255),
create_date TIMESTAMP,
description VARCHAR(10000),
direction VARCHAR(255),
guide_id INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
match_key_fields VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
on_match VARCHAR(255),
parent_map_id INTEGER,
partner_id INTEGER,
revision INTEGER,
secondary_key_path VARCHAR(255),
standard VARCHAR(255),
status VARCHAR(255),
table_name VARCHAR(255),
transaction_set VARCHAR(255),
version VARCHAR(255)
);
CREATE TABLE edi_map_rule (
condition VARCHAR(10000),
create_date TIMESTAMP,
default_value VARCHAR(255),
edi_path VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
map_id INTEGER,
modify_date TIMESTAMP,
notes VARCHAR(10000),
qqq_path VARCHAR(255),
required BOOLEAN,
sequence INTEGER,
transform VARCHAR(255),
transform_args VARCHAR(10000)
);
CREATE TABLE edi_map_sample (
create_date TIMESTAMP,
expected_result_json VARCHAR(10000),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
last_preview_at TIMESTAMP,
last_preview_status VARCHAR(255),
map_id INTEGER,
modify_date TIMESTAMP,
name VARCHAR(255),
payload_ref VARCHAR(255)
);
CREATE TABLE edi_outbound_message (
ack_for_group_id INTEGER,
ack_for_interchange_id INTEGER,
agreement_id INTEGER,
attempts INTEGER,
claimed_at TIMESTAMP,
create_date TIMESTAMP,
delivered_at TIMESTAMP,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
interchange_id INTEGER,
last_error VARCHAR(255),
modify_date TIMESTAMP,
next_attempt_at TIMESTAMP,
outbound_request_id INTEGER,
partner_id INTEGER,
payload_ref VARCHAR(255),
payload_sha256 VARCHAR(255),
sent_at TIMESTAMP,
size_bytes BIGINT,
status VARCHAR(255),
transport_id INTEGER,
transport_message_id VARCHAR(255),
transport_mic VARCHAR(255)
);
CREATE TABLE edi_outbound_request (
agreement_document_id INTEGER,
attempts INTEGER,
claimed_at TIMESTAMP,
create_date TIMESTAMP,
event_id VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
last_error VARCHAR(255),
modify_date TIMESTAMP,
next_attempt_at TIMESTAMP,
source_record_id VARCHAR(255),
source_table_name VARCHAR(255),
status VARCHAR(255),
UNIQUE (agreement_document_id, event_id)
);
CREATE TABLE edi_partner (
code VARCHAR(255),
contact_email VARCHAR(255),
contact_name VARCHAR(255),
contact_phone VARCHAR(255),
contains_phi BOOLEAN,
create_date TIMESTAMP,
default_timezone VARCHAR(255),
gateway_username VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
notes VARCHAR(10000),
status VARCHAR(255),
UNIQUE (name),
UNIQUE (code)
);
CREATE TABLE edi_payload (
ciphertext BYTEA,
create_date TIMESTAMP,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
key_id VARCHAR(255),
kind VARCHAR(255),
modify_date TIMESTAMP,
sha256 VARCHAR(255),
size_bytes BIGINT
);
CREATE TABLE edi_transaction (
ack_status VARCHAR(255),
agreement_document_id INTEGER,
business_key VARCHAR(255),
business_key_type VARCHAR(255),
contains_phi BOOLEAN,
control_number VARCHAR(255),
create_date TIMESTAMP,
direction VARCHAR(255),
error_count INTEGER,
functional_group_id INTEGER,
guide_id INTEGER,
guide_revision INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
inbound_position INTEGER,
interchange_id INTEGER,
map_id INTEGER,
map_revision INTEGER,
modify_date TIMESTAMP,
partner_id INTEGER,
phi_business_key VARCHAR(255),
secondary_key VARCHAR(255),
segment_count INTEGER,
status VARCHAR(255),
target_record_id VARCHAR(255),
target_table_name VARCHAR(255),
transaction_set VARCHAR(255),
version VARCHAR(255),
warning_count INTEGER
);
CREATE TABLE edi_transaction_error (
ack_code VARCHAR(255),
component_position INTEGER,
create_date TIMESTAMP,
element_position INTEGER,
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
loop_id VARCHAR(255),
message VARCHAR(10000),
modify_date TIMESTAMP,
segment_id VARCHAR(255),
segment_position INTEGER,
severity VARCHAR(255),
snip_level INTEGER,
transaction_id INTEGER,
value_excerpt VARCHAR(255)
);
CREATE TABLE edi_transport (
as2_local_id VARCHAR(255),
as2_partner_id VARCHAR(255),
as2_subject VARCHAR(255),
as2_url VARCHAR(255),
compress BOOLEAN,
create_date TIMESTAMP,
direction VARCHAR(255),
email_attachment_name VARCHAR(255),
email_from_alias VARCHAR(255),
email_provider_name VARCHAR(255),
email_to VARCHAR(255),
encrypt_algorithm VARCHAR(255),
gateway_endpoint_name VARCHAR(255),
http_auth_password_env_var VARCHAR(255),
http_auth_user_env_var VARCHAR(255),
https_auth_type VARCHAR(255),
https_headers_json VARCHAR(10000),
https_inbound_auth VARCHAR(255),
https_secret_env_var VARCHAR(255),
https_url VARCHAR(255),
id INTEGER GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
is_active BOOLEAN,
is_van BOOLEAN,
local_certificate_id INTEGER,
max_attempts INTEGER,
mdn_mode VARCHAR(255),
mdn_signed BOOLEAN,
modified_by VARCHAR(255),
modify_date TIMESTAMP,
name VARCHAR(255),
notes VARCHAR(10000),
partner_certificate_id INTEGER,
partner_id INTEGER,
partner_tls_certificate_id INTEGER,
sftp_after_pickup VARCHAR(255),
sftp_archive_path VARCHAR(255),
sftp_file_pattern VARCHAR(255),
sftp_host VARCHAR(255),
sftp_host_key_fingerprint VARCHAR(255),
sftp_inbound_path VARCHAR(255),
sftp_outbound_path VARCHAR(255),
sftp_password_env_var VARCHAR(255),
sftp_poll_cron VARCHAR(255),
sftp_port INTEGER,
sftp_private_key_env_var VARCHAR(255),
sftp_temp_suffix VARCHAR(255),
sftp_username VARCHAR(255),
sign_algorithm VARCHAR(255),
type VARCHAR(255),
van_name VARCHAR(255),
weak_crypto BOOLEAN,
UNIQUE (name)
);
Vendored
-295
View File
@@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"
Vendored
-189
View File
@@ -1,189 +0,0 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
+40 -181
View File
@@ -1,120 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
~ Copyright 2026 Kingsrook, LLC
~
~ Licensed under the Apache License, Version 2.0 (the "License");
~ you may not use this file except in compliance with the License.
~ You may obtain a copy of the License at
~
~ http://www.apache.org/licenses/LICENSE-2.0
~
~ Unless required by applicable law or agreed to in writing, software
~ distributed under the License is distributed on an "AS IS" BASIS,
~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
~ See the License for the specific language governing permissions and
~ limitations under the License.
-->
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<properties>
<revision>0.1.0-RC.2</revision>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<jacoco.version>0.8.15</jacoco.version>
</properties>
<parent>
<groupId>com.kingsrook</groupId>
<artifactId>qbit-build-parent</artifactId>
<version>2.0.0</version>
</parent>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-edi</artifactId>
<version>${revision}</version>
<packaging>jar</packaging>
<name>QBit EDI</name>
<description>QQQ QBit: EDI (X12 and EDIFACT) trading partners, transports (AS2, SFTP, VAN, HTTPS, email), acknowledgements, data-driven implementation guides and maps</description>
<url>https://github.com/QRun-IO/qbit-edi</url>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<licenses>
<license>
<name>Apache License, Version 2.0</name>
<url>https://www.apache.org/licenses/LICENSE-2.0</url>
<distribution>repo</distribution>
</license>
</licenses>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-example-app</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<developers>
<developer>
<id>kingsrook</id>
<name>Kingsrook Team</name>
<email>contact@kingsrook.com</email>
<organization>Kingsrook, LLC</organization>
<organizationUrl>https://kingsrook.com</organizationUrl>
</developer>
</developers>
<scm>
<connection>scm:git:git://github.com/QRun-IO/qbit-edi.git</connection>
<developerConnection>scm:git:ssh://github.com:QRun-IO/qbit-edi.git</developerConnection>
<url>https://github.com/QRun-IO/qbit-edi</url>
<tag>HEAD</tag>
</scm>
<name>QBit Example Application</name>
<description>Template for Application QBits - complete mini-applications with UI navigation</description>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<qqq.version>4.0.0</qqq.version>
</properties>
<dependencies>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-frontend-next</artifactId><version>1.0.0-RC.14</version><scope>test</scope></dependency>
<dependency><groupId>org.bouncycastle</groupId><artifactId>bcpkix-jdk18on</artifactId><version>1.85</version></dependency>
<dependency><groupId>com.helger.as2</groupId><artifactId>as2-lib</artifactId><version>5.1.8</version></dependency>
<dependency><groupId>com.kingsrook.qqq</groupId><artifactId>qqq-backend-module-filesystem</artifactId></dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-esb</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qbits</groupId>
<artifactId>qbit-object-storage</artifactId>
<version>0.1.0-RC.2</version>
<optional>true</optional>
<exclusions>
<exclusion><groupId>com.kingsrook.qqq</groupId><artifactId>*</artifactId></exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>io.xlate</groupId>
<artifactId>staedi</artifactId>
<version>1.26.4</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.4.240</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-middleware-javalin</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
</dependency>
<!-- QQQ Backend Core -->
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>${qqq.version}</version>
</dependency>
<!-- Testing -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
@@ -123,109 +40,51 @@
<version>3.27.7</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-module-rdbms</artifactId>
<version>${qqq.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.2.224</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.3.1</version>
<dependencies>
<dependency>
<groupId>com.puppycrawl.tools</groupId>
<artifactId>checkstyle</artifactId>
<version>10.12.6</version>
</dependency>
</dependencies>
<configuration>
<configLocation>checkstyle/config.xml</configLocation>
<headerLocation>checkstyle/license.txt</headerLocation>
<encoding>UTF-8</encoding>
<consoleOutput>true</consoleOutput>
<failsOnError>false</failsOnError>
<failOnViolation>true</failOnViolation>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>validate</id>
<phase>validate</phase>
<goals>
<goal>check</goal>
</goals>
</execution>
</executions>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.11.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
</plugin>
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<version>0.8.10</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>verify</phase>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>INSTRUCTION</counter>
<value>COVEREDRATIO</value>
<minimum>0.70</minimum>
</limit>
<limit>
<counter>CLASS</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
<profiles>
<profile>
<id>qqq-snapshot</id>
<activation>
<activeByDefault>true</activeByDefault>
</activation>
<properties>
<qqq.snapshot.version>4.1.0-RC.5</qqq.snapshot.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-bom-pom</artifactId>
<version>${qqq.snapshot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
</profile>
</profiles>
</project>
@@ -1,373 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi;
import java.util.List;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitConfig;
/*******************************************************************************
** Code configuration for the EDI qbit. Runtime settings (partners, agreements,
** transports, guides and maps) are data in the edi_ tables, not here.
*******************************************************************************/
public class EdiQBitConfig implements QBitConfig
{
public static final String TABLE_NAME_PREFIX = "edi_";
public static final String PERMISSION_BASE_NAME = "edi";
private final java.util.Set<String> sendAsEdiTables = new java.util.LinkedHashSet<>();
/*******************************************************************************
** Opt a source table into the manual Send As EDI action.
*******************************************************************************/
public EdiQBitConfig withSendAsEdiTable(String tableName)
{
sendAsEdiTables.add(tableName);
return (this);
}
/*******************************************************************************
** Explicitly opted-in manual source tables.
*******************************************************************************/
public java.util.Set<String> getSendAsEdiTables()
{
return (java.util.Set.copyOf(sendAsEdiTables));
}
private String payloadBucketAlias = "edi";
private java.util.function.Supplier<com.kingsrook.qqq.backend.core.model.session.QSession> serviceSessionSupplier = null;
private java.time.Clock clock = java.time.Clock.systemUTC();
private String esbDestinationName;
private String schedulerName;
private Integer maxSegmentsPerTransaction = 100000;
private String payloadStore = "DATABASE";
private com.kingsrook.qbits.edi.security.EdiSecrets secretsResolver = System::getenv;
private Boolean partnerLocks = false;
private String backendName;
private String appName = "edi";
private String appLabel = "EDI";
private String parentAppName;
private String publicBaseUrl;
/*******************************************************************************
** Validate the configuration, collecting all errors.
*******************************************************************************/
@Override
public void validate(QInstance instance, List<String> errors)
{
if(backendName == null || instance.getBackend(backendName) == null)
{
errors.add("backendName must name an existing backend");
}
if(maxSegmentsPerTransaction == null || maxSegmentsPerTransaction < 2)
{
errors.add("maxSegmentsPerTransaction must be at least 2");
}
if(!List.of("DATABASE", "OBJECT_STORAGE").contains(payloadStore))
{
errors.add("payloadStore must be DATABASE or OBJECT_STORAGE");
}
if(secretsResolver == null || partnerLocks == null)
{
errors.add("secretsResolver and partnerLocks are required");
}
if(appName == null || appName.isBlank())
{
errors.add("appName is required");
}
}
/*******************************************************************************
** The backend for the qbit's tables.
*******************************************************************************/
@Override
public String getDefaultBackendNameForTables()
{
return (backendName);
}
/*******************************************************************************
** Getter for backendName
*******************************************************************************/
public String getBackendName()
{
return (this.backendName);
}
/*******************************************************************************
** Fluent setter for backendName
*******************************************************************************/
public EdiQBitConfig withBackendName(String backendName)
{
this.backendName = backendName;
return (this);
}
/*******************************************************************************
** Getter for appName
*******************************************************************************/
public String getAppName()
{
return (this.appName);
}
/*******************************************************************************
** Fluent setter for appName
*******************************************************************************/
public EdiQBitConfig withAppName(String appName)
{
this.appName = appName;
return (this);
}
/*******************************************************************************
** Getter for appLabel
*******************************************************************************/
public String getAppLabel()
{
return (this.appLabel);
}
/*******************************************************************************
** Fluent setter for appLabel
*******************************************************************************/
public EdiQBitConfig withAppLabel(String appLabel)
{
this.appLabel = appLabel;
return (this);
}
/*******************************************************************************
** Getter for parentAppName
*******************************************************************************/
public String getParentAppName()
{
return (this.parentAppName);
}
/*******************************************************************************
** Fluent setter for parentAppName
*******************************************************************************/
public EdiQBitConfig withParentAppName(String parentAppName)
{
this.parentAppName = parentAppName;
return (this);
}
/*******************************************************************************
** Get schedulerName.
*******************************************************************************/
public String getSchedulerName()
{
return (schedulerName);
}
/*******************************************************************************
** Configure schedulerName.
*******************************************************************************/
public EdiQBitConfig withSchedulerName(String value)
{
schedulerName = value;
return (this);
}
/*******************************************************************************
** Get maxSegmentsPerTransaction.
*******************************************************************************/
public Integer getMaxSegmentsPerTransaction()
{
return (maxSegmentsPerTransaction);
}
/*******************************************************************************
** Configure maxSegmentsPerTransaction.
*******************************************************************************/
public EdiQBitConfig withMaxSegmentsPerTransaction(Integer value)
{
maxSegmentsPerTransaction = value;
return (this);
}
/*******************************************************************************
** Get payloadStore.
*******************************************************************************/
@JsonIgnore
public String getPayloadStore()
{
return (payloadStore);
}
/*******************************************************************************
** Configure payloadStore.
*******************************************************************************/
public EdiQBitConfig withPayloadStore(String value)
{
payloadStore = value;
return (this);
}
/*******************************************************************************
** Get secretsResolver.
*******************************************************************************/
@JsonIgnore
public com.kingsrook.qbits.edi.security.EdiSecrets getSecretsResolver()
{
return (secretsResolver);
}
/*******************************************************************************
** Configure secretsResolver.
*******************************************************************************/
public EdiQBitConfig withSecretsResolver(com.kingsrook.qbits.edi.security.EdiSecrets value)
{
secretsResolver = value;
return (this);
}
/*******************************************************************************
** Get partnerLocks.
*******************************************************************************/
public Boolean getPartnerLocks()
{
return (partnerLocks);
}
/*******************************************************************************
** Configure partnerLocks.
*******************************************************************************/
public EdiQBitConfig withPartnerLocks(Boolean value)
{
partnerLocks = value;
return (this);
}
/*******************************************************************************
** Get payloadBucketAlias.
*******************************************************************************/
public String getPayloadBucketAlias()
{
return (payloadBucketAlias);
}
/*******************************************************************************
** Configure payloadBucketAlias.
*******************************************************************************/
public EdiQBitConfig withPayloadBucketAlias(String value)
{
payloadBucketAlias = value;
return (this);
}
/*******************************************************************************
** Get serviceSessionSupplier.
*******************************************************************************/
@JsonIgnore
public java.util.function.Supplier<com.kingsrook.qqq.backend.core.model.session.QSession> getServiceSessionSupplier()
{
return (serviceSessionSupplier);
}
/*******************************************************************************
** Configure serviceSessionSupplier.
*******************************************************************************/
public EdiQBitConfig withServiceSessionSupplier(java.util.function.Supplier<com.kingsrook.qqq.backend.core.model.session.QSession> value)
{
serviceSessionSupplier = value;
return (this);
}
/*******************************************************************************
** Get clock.
*******************************************************************************/
@JsonIgnore
public java.time.Clock getClock()
{
return (clock);
}
/*******************************************************************************
** Configure clock.
*******************************************************************************/
public EdiQBitConfig withClock(java.time.Clock value)
{
clock = value;
return (this);
}
/*******************************************************************************
** Optional destination for core table events on main tables.
*******************************************************************************/
public String getEsbDestinationName()
{
return (esbDestinationName);
}
/*******************************************************************************
** Enable standard table publications to a host-configured destination.
*******************************************************************************/
public EdiQBitConfig withEsbDestinationName(String value)
{
esbDestinationName = value;
return (this);
}
/*******************************************************************************
** Public base URL of this deployment (for example https://edi.example.com),
** used to build the asynchronous AS2 receipt address
** {base}/qqq/hooks/edi/as2/{transport}/mdn.
*******************************************************************************/
public String getPublicBaseUrl()
{
return (publicBaseUrl);
}
/*******************************************************************************
** Set the public base URL.
*******************************************************************************/
public EdiQBitConfig withPublicBaseUrl(String value)
{
publicBaseUrl = value;
return (this);
}
}
@@ -1,151 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitMetaDataProducer;
/*******************************************************************************
** Entry point for the EDI qbit; component producers are scanned only in the
** metadata package.
*******************************************************************************/
public class EdiQBitProducer implements QBitMetaDataProducer<EdiQBitConfig>
{
public static final String GROUP_ID = "com.kingsrook.qbits";
public static final String ARTIFACT_ID = "qbit-edi";
public static final String VERSION = "0.1.0";
private EdiQBitConfig qBitConfig;
private String namespace;
/*******************************************************************************
** Fluent configuration setter.
*******************************************************************************/
public EdiQBitProducer withQBitConfig(EdiQBitConfig config)
{
qBitConfig = config;
return (this);
}
/*******************************************************************************
** Configuration for component producers.
*******************************************************************************/
@Override
public EdiQBitConfig getQBitConfig()
{
return (qBitConfig);
}
/*******************************************************************************
** Fluent namespace setter.
*******************************************************************************/
public EdiQBitProducer withNamespace(String namespace)
{
this.namespace = namespace;
return (this);
}
/*******************************************************************************
** Qbit namespace.
*******************************************************************************/
@Override
public String getNamespace()
{
return (namespace);
}
/*******************************************************************************
** Qbit identity.
*******************************************************************************/
@Override
public QBitMetaData getQBitMetaData()
{
return (new QBitMetaData().withGroupId(GROUP_ID).withArtifactId(ARTIFACT_ID).withVersion(VERSION).withNamespace(namespace).withConfig(qBitConfig));
}
/*******************************************************************************
** Unconfigured producers discovered by host scanning are disabled.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (qBitConfig != null);
}
/*******************************************************************************
** Only the metadata package holds component producers.
*******************************************************************************/
@Override
public String getPackageNameForFindingMetaDataProducers()
{
return ("com.kingsrook.qbits.edi.metadata");
}
/*******************************************************************************
** Validate the configuration and install the qbit's metadata.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
{
qBitConfig.validate(instance);
MetaDataProducerMultiOutput output = QBitMetaDataProducer.super.produce(instance);
output.addSelfToInstance(instance);
EdiRuntime.install(instance, qBitConfig);
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.metadata.EdiRecordLabels());
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.flow.OutboundEnqueue.class));
instance.withSupplementalMetaData(new com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData()
.withPlugin(new com.kingsrook.qbits.edi.transport.gateway.GatewayTransport())
.withPlugin(new com.kingsrook.qbits.edi.transport.HttpsTransport()).withPlugin(new com.kingsrook.qbits.edi.transport.SftpTransport())
.withPlugin(new com.kingsrook.qbits.edi.as2.As2Transport()).withPlugin(new com.kingsrook.qbits.edi.transport.UnavailableTransport("EMAIL")));
try
{
com.kingsrook.qqq.middleware.javalin.QJavalinMetaData.ofOrWithNew(instance).withAdditionalRouteProviderReference(
new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.transport.HttpsRouteProvider.class))
.withAdditionalRouteProviderReference(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.as2.As2RouteProvider.class));
}
catch(NoClassDefFoundError optionalMiddlewareAbsent)
{
// Machine hooks are installed only when optional Javalin is present.
}
return (new MetaDataProducerMultiOutput());
}
}
@@ -1,214 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.WeakHashMap;
import com.kingsrook.qbits.edi.ack.Ack997Builder;
import com.kingsrook.qbits.edi.ack.Ack999Builder;
import com.kingsrook.qbits.edi.ack.InboundAckReader;
import com.kingsrook.qbits.edi.ack.TA1Builder;
import com.kingsrook.qbits.edi.guide.BuiltInGuideLoader;
import com.kingsrook.qbits.edi.guide.GuideValidator;
import com.kingsrook.qbits.edi.storage.DatabasePayloadStore;
import com.kingsrook.qbits.edi.storage.PayloadCrypto;
import com.kingsrook.qbits.edi.x12.ControlNumberAllocator;
import com.kingsrook.qbits.edi.x12.DuplicateDetector;
import com.kingsrook.qbits.edi.x12.X12EnvelopeReader;
import com.kingsrook.qbits.edi.x12.X12Writer;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.module.rdbms.model.metadata.RDBMSBackendMetaData;
/*******************************************************************************
** Per-QInstance service holder; metadata installation does not access the database.
*******************************************************************************/
public final class EdiRuntime
{
private static final QLogger LOG = QLogger.getLogger(EdiRuntime.class);
private static final Map<QInstance, EdiRuntime> INSTANCES = Collections.synchronizedMap(new WeakHashMap<>());
private final X12EnvelopeReader reader;
private final X12Writer writer = new X12Writer();
private final GuideValidator validator = new GuideValidator();
private final BuiltInGuideLoader guides = new BuiltInGuideLoader();
private final com.kingsrook.qbits.edi.storage.PayloadStore payloads;
private final EdiQBitConfig config;
private final com.kingsrook.qbits.edi.map.TransformRegistry transforms = new com.kingsrook.qbits.edi.map.TransformRegistry();
private final DuplicateDetector duplicates = new DuplicateDetector();
private final ControlNumberAllocator controls;
private final Ack997Builder ack997 = new Ack997Builder();
private final Ack999Builder ack999 = new Ack999Builder();
private final TA1Builder ta1 = new TA1Builder();
private final InboundAckReader acknowledgements = new InboundAckReader();
/*******************************************************************************
** Construct services without resolving keys until content is stored or opened.
*******************************************************************************/
private EdiRuntime(QInstance instance, EdiQBitConfig config)
{
this.config = config;
reader = new X12EnvelopeReader(config.getMaxSegmentsPerTransaction());
payloads = "OBJECT_STORAGE".equals(config.getPayloadStore())
? new com.kingsrook.qbits.edi.storage.ObjectStoragePayloadStore(new PayloadCrypto(config.getSecretsResolver()), config.getPayloadBucketAlias(), config.getClock())
: new DatabasePayloadStore(new PayloadCrypto(config.getSecretsResolver()));
controls = instance.getBackend(config.getBackendName()) instanceof RDBMSBackendMetaData backend ? new ControlNumberAllocator(backend) : null;
}
/*******************************************************************************
** Install for a host instance.
*******************************************************************************/
public static void install(QInstance instance, EdiQBitConfig config)
{
INSTANCES.put(instance, new EdiRuntime(instance, config));
}
/*******************************************************************************
** Get services for the current QQQ context.
*******************************************************************************/
public static EdiRuntime current()
{
return (Objects.requireNonNull(INSTANCES.get(QContext.getQInstance()), "EDI runtime has not been installed"));
}
/*******************************************************************************
** Call at host startup after DDL, with the service session established.
*******************************************************************************/
public List<Integer> initialize() throws Exception
{
if("DATABASE".equals(config.getPayloadStore()))
{
LOG.warn("EDI database payload storage is enabled; object storage is recommended for production");
}
var ids = guides.load();
com.kingsrook.qbits.edi.processes.OutboundProcesses.installSchedules();
return (ids);
}
/*******************************************************************************
** Access reader.
*******************************************************************************/
public X12EnvelopeReader reader()
{
return (reader);
}
/*******************************************************************************
** Access writer.
*******************************************************************************/
public X12Writer writer()
{
return (writer);
}
/*******************************************************************************
** Access validator.
*******************************************************************************/
public GuideValidator validator()
{
return (validator);
}
/*******************************************************************************
** Access guides.
*******************************************************************************/
public BuiltInGuideLoader guides()
{
return (guides);
}
/*******************************************************************************
** Access payloads.
*******************************************************************************/
public com.kingsrook.qbits.edi.storage.PayloadStore payloads()
{
return (payloads);
}
/*******************************************************************************
** Access duplicates.
*******************************************************************************/
public DuplicateDetector duplicates()
{
return (duplicates);
}
/*******************************************************************************
** Access controls.
*******************************************************************************/
public ControlNumberAllocator controls()
{
return (Objects.requireNonNull(controls, "Control allocation requires an RDBMS backend"));
}
/*******************************************************************************
** Access ack997.
*******************************************************************************/
public Ack997Builder ack997()
{
return (ack997);
}
/*******************************************************************************
** Access ack999.
*******************************************************************************/
public Ack999Builder ack999()
{
return (ack999);
}
/*******************************************************************************
** Access ta1.
*******************************************************************************/
public TA1Builder ta1()
{
return (ta1);
}
/*******************************************************************************
** Access acknowledgements.
*******************************************************************************/
public InboundAckReader acknowledgements()
{
return (acknowledgements);
}
/*******************************************************************************
** Installed host configuration.
*******************************************************************************/
public EdiQBitConfig config()
{
return (config);
}
/*******************************************************************************
** Host-extensible inbound transformations.
*******************************************************************************/
public com.kingsrook.qbits.edi.map.TransformRegistry transforms()
{
return (transforms);
}
}
@@ -1,43 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.guide.ValidationFinding;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
/*******************************************************************************
** Generate an X12 997 functional acknowledgement.
*******************************************************************************/
public class Ack997Builder
{
/*******************************************************************************
** Build transaction detail at the configured acknowledgement level.
*******************************************************************************/
public X12TransactionSet build(X12FunctionalGroup group, Map<String, List<ValidationFinding>> findings, AckLevel level, String controlNumber)
{
return (FunctionalAckBuilder.build(false, group, findings, level, controlNumber));
}
}
@@ -1,43 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.guide.ValidationFinding;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
/*******************************************************************************
** Generate an X12 999 functional acknowledgement.
*******************************************************************************/
public class Ack999Builder
{
/*******************************************************************************
** Build transaction detail at the configured acknowledgement level.
*******************************************************************************/
public X12TransactionSet build(X12FunctionalGroup group, Map<String, List<ValidationFinding>> findings, AckLevel level, String controlNumber)
{
return (FunctionalAckBuilder.build(true, group, findings, level, controlNumber));
}
}
@@ -1,60 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12Interchange;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
/*******************************************************************************
** Address acknowledgements to the original sender using newly allocated controls.
*******************************************************************************/
public final class AckEnvelope
{
/*******************************************************************************
** Swap interchange identities while preserving standard version and usage.
*******************************************************************************/
public static X12Segment replyIsa(X12Segment original, String control, LocalDateTime at)
{
return (X12Segment.of("ISA", 1, "00", "", "00", "", original.value(7), original.value(8), original.value(5), original.value(6),
at.format(DateTimeFormatter.ofPattern("yyMMdd")), at.format(DateTimeFormatter.ofPattern("HHmm")), original.value(11), original.value(12),
"0".repeat(Math.max(0, 9 - control.length())) + control, "0", original.value(15), original.value(16)));
}
/*******************************************************************************
** Wrap an acknowledgement transaction in a reply functional group and interchange.
*******************************************************************************/
public static X12Interchange wrap(X12Interchange received, X12FunctionalGroup group, X12TransactionSet acknowledgement, String isaControl, String gsControl, LocalDateTime at)
{
X12Segment isa = replyIsa(received.isa(), isaControl, at);
String version = acknowledgement.st().value(1).equals("999") ? "005010X231A1" : group.gs().value(8);
X12Segment gs = X12Segment.of("GS", 2, "FA", group.gs().value(3), group.gs().value(2), at.format(DateTimeFormatter.BASIC_ISO_DATE),
at.format(DateTimeFormatter.ofPattern("HHmm")), gsControl, "X", version);
X12FunctionalGroup reply = new X12FunctionalGroup(gs, X12Segment.of("GE", 0, "1", gsControl), List.of(acknowledgement), List.of());
return (new X12Interchange(received.separators(), isa, X12Segment.of("IEA", 0, "1", isa.value(13)), List.of(reply), List.of(), List.of()));
}
}
@@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
/*******************************************************************************
** Transaction detail included in a functional acknowledgement.
*******************************************************************************/
public enum AckLevel
{
GROUP_ONLY, ALL_TRANSACTIONS, ERRORS_ONLY
}
@@ -1,137 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.guide.ValidationFinding;
import com.kingsrook.qbits.edi.x12.EnvelopeFinding;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
/*******************************************************************************
** Shared 997/999 status, counts and detail generation.
*******************************************************************************/
final class FunctionalAckBuilder
{
/*******************************************************************************
** Build an acknowledgement transaction with its own ST control number.
*******************************************************************************/
static X12TransactionSet build(boolean implementation, X12FunctionalGroup group, Map<String, List<ValidationFinding>> findings, AckLevel level, String control)
{
List<X12Segment> segments = new ArrayList<>();
if(implementation)
{
add(segments, "ST", "999", control, "005010X231A1");
add(segments, "AK1", group.gs().value(1), group.gs().value(6), group.gs().value(8));
}
else
{
add(segments, "ST", "997", control);
add(segments, "AK1", group.gs().value(1), group.gs().value(6));
}
int accepted = 0;
boolean hasWarnings = false;
for(X12TransactionSet transaction : group.transactions())
{
List<ValidationFinding> errors = findings.getOrDefault(transaction.st().value(2), List.of());
boolean rejected = !transaction.findings().isEmpty() || errors.stream().anyMatch(f -> "ERROR".equals(f.severity()));
String status = rejected ? "R" : errors.isEmpty() ? "A" : "E";
if(!rejected)
{
accepted++;
}
hasWarnings |= status.equals("E");
if(level == AckLevel.GROUP_ONLY || level == AckLevel.ERRORS_ONLY && status.equals("A"))
{
continue;
}
if(implementation && !transaction.st().value(3).isEmpty())
{
add(segments, "AK2", transaction.st().value(1), transaction.st().value(2), transaction.st().value(3));
}
else
{
add(segments, "AK2", transaction.st().value(1), transaction.st().value(2));
}
for(ValidationFinding finding : errors)
{
add(segments, implementation ? "IK3" : "AK3", finding.segmentId(), String.valueOf(finding.segmentPosition()),
finding.loopId() == null ? "" : finding.loopId(), implementation ? finding.ik3Code() : finding.ak3Code());
if(finding.elementPosition() != null)
{
List<String> position = new ArrayList<>(List.of(finding.elementPosition().toString()));
if(finding.componentPosition() != null)
{
position.add(finding.componentPosition().toString());
}
segments.add(new X12Segment(implementation ? "IK4" : "AK4", segments.size() + 1,
List.of(List.of(position), List.of(List.of("")), List.of(List.of(implementation ? finding.ik4Code() : finding.ak4Code())))));
}
if(implementation && finding.context() != null)
{
var context = finding.context();
List<List<List<String>>> values = new ArrayList<>();
for(String value : List.of("SITUATIONAL TRIGGER", context.segmentId(), String.valueOf(context.segmentPosition()), context.loopId() == null ? "" : context.loopId()))
{
values.add(List.of(List.of(value)));
}
if(context.elementPosition() != null)
{
List<String> position = new ArrayList<>(List.of(context.elementPosition().toString()));
if(context.componentPosition() != null)
{
position.add(context.componentPosition().toString());
}
values.add(List.of(position));
}
segments.add(new X12Segment("CTX", segments.size() + 1, values));
}
}
List<String> statusElements = new ArrayList<>(List.of(status));
transaction.findings().stream().map(EnvelopeFinding::code).distinct().limit(5).forEach(statusElements::add);
if(rejected && statusElements.size() == 1)
{
statusElements.add("5");
}
add(segments, implementation ? "IK5" : "AK5", statusElements.toArray(String[]::new));
}
String status = !group.findings().isEmpty() || accepted == 0 && !group.transactions().isEmpty() ? "R"
: accepted < group.transactions().size() ? "P" : hasWarnings ? "E" : "A";
List<String> summary = new ArrayList<>(List.of(status, group.ge().value(1), String.valueOf(group.transactions().size()), String.valueOf(accepted)));
group.findings().stream().map(EnvelopeFinding::code).distinct().limit(5).forEach(summary::add);
add(segments, "AK9", summary.toArray(String[]::new));
add(segments, "SE", String.valueOf(segments.size() + 1), control);
return (new X12TransactionSet(segments.getFirst(), segments.getLast(), List.copyOf(segments), List.of()));
}
/*******************************************************************************
** Append a positioned simple segment.
*******************************************************************************/
private static void add(List<X12Segment> segments, String id, String... values)
{
segments.add(X12Segment.of(id, segments.size() + 1, values));
}
}
@@ -1,107 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12Interchange;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
/*******************************************************************************
** Read acknowledgement controls and outcomes for later reconciliation.
*******************************************************************************/
public class InboundAckReader
{
/*******************************************************************************
** Parsed functional or interchange acknowledgement.
*******************************************************************************/
public record Result(String type, String controlNumber, String status, Map<String, String> transactionStatuses, String noteCode)
{
}
/*******************************************************************************
** Read all received acknowledgements in an interchange.
*******************************************************************************/
public List<Result> read(X12Interchange interchange)
{
List<Result> results = new ArrayList<>();
for(X12Segment segment : interchange.serviceSegments())
{
if(segment.id().equals("TA1"))
{
results.add(new Result("TA1", segment.value(1), segment.value(4), Map.of(), segment.value(5)));
}
}
for(X12FunctionalGroup group : interchange.groups())
{
for(X12TransactionSet transaction : group.transactions())
{
if(List.of("997", "999").contains(transaction.st().value(1)))
{
results.add(read(transaction));
}
}
}
return (results);
}
/*******************************************************************************
** Extract AK1, AK2, AK5/IK5 and AK9 without acknowledging the ack.
*******************************************************************************/
public Result read(X12TransactionSet transaction)
{
String control = null;
String status = null;
String stControl = null;
Map<String, String> statuses = new LinkedHashMap<>();
for(X12Segment segment : transaction.segments())
{
switch(segment.id())
{
case "AK1" -> control = segment.value(2);
case "AK2" -> stControl = segment.value(2);
case "AK5", "IK5" ->
{
if(stControl != null)
{
statuses.put(stControl, segment.value(1));
}
}
case "AK9" -> status = segment.value(1);
default ->
{
// Detail segments do not change reconciliation status.
}
}
}
if(control == null || status == null)
{
throw new IllegalArgumentException("Incomplete functional acknowledgement");
}
return (new Result(transaction.st().value(1), control, status, Map.copyOf(statuses), null));
}
}
@@ -1,59 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.ack;
import java.time.LocalDateTime;
import java.util.List;
import com.kingsrook.qbits.edi.x12.X12Interchange;
import com.kingsrook.qbits.edi.x12.X12Segment;
/*******************************************************************************
** TA1 is an interchange-level service segment, never an ST/SE transaction.
*******************************************************************************/
public class TA1Builder
{
/*******************************************************************************
** Select the first interchange finding, or acceptance when there are none.
*******************************************************************************/
public X12Segment build(X12Segment isa, List<com.kingsrook.qbits.edi.x12.EnvelopeFinding> findings)
{
return (build(isa, findings.stream().filter(f -> "TA1".equals(f.level())).map(com.kingsrook.qbits.edi.x12.EnvelopeFinding::code).findFirst().orElse("000")));
}
/*******************************************************************************
** Acknowledge original ISA control, date and time with a TA1 note code.
*******************************************************************************/
public X12Segment build(X12Segment isa, String noteCode)
{
return (X12Segment.of("TA1", 2, isa.value(13), isa.value(9), isa.value(10), "000".equals(noteCode) ? "A" : "R", noteCode));
}
/*******************************************************************************
** Generate a reply interchange with swapped identities and its own ISA13.
*******************************************************************************/
public X12Interchange interchange(X12Interchange received, String control, String noteCode, LocalDateTime at)
{
X12Segment isa = AckEnvelope.replyIsa(received.isa(), control, at);
return (new X12Interchange(received.separators(), isa, X12Segment.of("IEA", 3, "0", isa.value(13)), List.of(), List.of(build(received.isa(), noteCode)), List.of()));
}
}
@@ -1,478 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.app;
import java.io.Serializable;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.edi.EdiQBitConfig;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.AbstractWidgetRenderer;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.CountAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.count.CountInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetInput;
import com.kingsrook.qqq.backend.core.model.actions.widgets.RenderWidgetOutput;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.StatisticsData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.TableData;
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppSection;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Typed EDI home. Every data query explicitly enforces the viewing user's access.
*******************************************************************************/
public class EdiHome extends AbstractWidgetRenderer
{
public static final List<String> ACTIVITY = List.of("edi_transaction", "edi_interchange", "edi_exception");
public static final List<String> SETUP = List.of("edi_partner", "edi_agreement", "edi_transport", "edi_certificate", "edi_guide", "edi_code_list", "edi_map");
private static final Map<String, String> LABELS = new LinkedHashMap<>();
private static final Map<String, String> DOCUMENT_NAMES = new HashMap<>();
private static final List<String> TABLES = List.of("queue", "acks", "errors", "volume", "system");
static
{
DOCUMENT_NAMES.putAll(Map.ofEntries(Map.entry("810", "Invoice"), Map.entry("846", "Inventory Advice"), Map.entry("850", "Purchase Order"),
Map.entry("855", "PO Acknowledgment"), Map.entry("856", "Ship Notice"), Map.entry("860", "PO Change"), Map.entry("940", "Warehouse Shipping Order"),
Map.entry("945", "Warehouse Shipping Advice"), Map.entry("214", "Shipment Status"), Map.entry("204", "Load Tender"), Map.entry("837", "Claim"),
Map.entry("835", "Remittance"), Map.entry("270", "Eligibility Inquiry"), Map.entry("271", "Eligibility Response"), Map.entry("276", "Claim Status Inquiry"),
Map.entry("277", "Claim Status Response"), Map.entry("834", "Enrollment"), Map.entry("997", "Functional Ack"), Map.entry("999", "Implementation Ack"),
Map.entry("TA1", "Interchange Ack")));
LABELS.put("in_today", "Inbound today");
LABELS.put("out_today", "Outbound today");
LABELS.put("exceptions", "Open exceptions");
LABELS.put("overdue", "Overdue acks");
LABELS.put("dead", "Dead letters");
LABELS.put("certificates", "Certificates expiring (30 days)");
LABELS.put("queue", "Exception queue");
LABELS.put("acks", "Overdue acknowledgements");
LABELS.put("errors", "Recent errors (24 hours)");
LABELS.put("volume", "Volume by partner and document (7 days)");
LABELS.put("system", "System");
}
/*******************************************************************************
** Activity and setup only; all other tables remain parent record children.
*******************************************************************************/
public static QAppMetaData app(EdiQBitConfig config, List<QTableMetaData> tables)
{
QAppMetaData app = new QAppMetaData().withName(config.getAppName()).withLabel(config.getAppLabel()).withIcon(new QIcon("swap_horiz"))
.withWidgets(LABELS.keySet().stream().map(n -> "edi_home_" + n).toList())
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))
.withSection(new QAppSection().withName("activity").withLabel("Activity").withTables(ACTIVITY))
.withSection(new QAppSection().withName("setup").withLabel("Setup").withTables(SETUP));
app.setParentAppName(config.getParentAppName());
tables.stream().filter(t -> ACTIVITY.contains(t.getName()) || SETUP.contains(t.getName())).forEach(app::withChild);
return (app);
}
/*******************************************************************************
** RC.5 typed statistics and tables; no raw markup in metadata or widget data.
*******************************************************************************/
public static List<QWidgetMetaData> widgets()
{
List<QWidgetMetaData> widgets = new ArrayList<>();
LABELS.forEach((name, label) -> widgets.add(new QWidgetMetaData().withName("edi_home_" + name).withLabel(label)
.withType((TABLES.contains(name) ? WidgetType.TABLE : WidgetType.STATISTICS).getType())
.withGridColumns(TABLES.contains(name) ? 12 : 2).withCodeReference(new QCodeReference(EdiHome.class))
.withPermissionRules(new QPermissionRules().withLevel(PermissionLevel.NOT_PROTECTED))));
return (widgets);
}
/*******************************************************************************
** Direct rendering also enforces table permissions, including nested process calls.
*******************************************************************************/
@Override
public RenderWidgetOutput render(RenderWidgetInput input) throws QException
{
String name = input.getWidgetMetaData().getName().substring("edi_home_".length());
Instant now = EdiRuntime.current().config().getClock().instant();
return (switch(name)
{
case "in_today", "out_today" -> count("edi_transaction", new QQueryFilter()
.withCriteria("direction", QCriteriaOperator.EQUALS, name.startsWith("in_") ? "INBOUND" : "OUTBOUND")
.withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.truncatedTo(ChronoUnit.DAYS)));
case "exceptions" -> count("edi_exception", openExceptions());
case "overdue" -> count("edi_functional_group", overdueAcks(now));
case "dead" -> count("edi_exception", openExceptions().withCriteria("type", QCriteriaOperator.IN, "DEAD_LETTER", "TRANSPORT"));
case "certificates" -> count("edi_certificate", new QQueryFilter().withCriteria("notAfter", QCriteriaOperator.LESS_THAN_OR_EQUALS, now.plus(30, ChronoUnit.DAYS))
.withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
case "queue" -> queue(now);
case "acks" -> acks(now);
case "errors" -> errors(now);
case "volume" -> volume(now);
case "system" -> system(now);
default -> throw new QException("Unknown EDI home widget " + name);
});
}
/*******************************************************************************
** A counted statistic linking to the same filter.
*******************************************************************************/
private static RenderWidgetOutput count(String table, QQueryFilter filter) throws QException
{
return (new RenderWidgetOutput(new StatisticsData().withCount(countOf(table, filter)).withCountURL(link(table, filter))));
}
/*******************************************************************************
** Count with enforcement.
*******************************************************************************/
private static Integer countOf(String table, QQueryFilter filter) throws QException
{
CountInput countInput = new CountInput(table).withFilter(filter);
countInput.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new CountAction().execute(countInput).getCount());
}
/*******************************************************************************
** Open and in-progress exceptions.
*******************************************************************************/
private static QQueryFilter openExceptions()
{
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS"));
}
/*******************************************************************************
** Outbound groups whose expected acknowledgement is past due.
*******************************************************************************/
private static QQueryFilter overdueAcks(Instant now)
{
return (new QQueryFilter().withCriteria("ackStatus", QCriteriaOperator.EQUALS, "PENDING").withCriteria("ackDueAt", QCriteriaOperator.LESS_THAN, now));
}
/*******************************************************************************
** Open exceptions, oldest first, in four narrow columns.
*******************************************************************************/
private static RenderWidgetOutput queue(Instant now) throws QException
{
Map<Integer, String> partners = partnerNames();
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord row : query("edi_exception", openExceptions().withOrderBy(new QFilterOrderBy("createDate", true)).withOrderBy(new QFilterOrderBy("id", true)).withLimit(20)))
{
rows.add(row("Exception", plain(row.getValueString("type")) + ": " + row.getValueString("summary"),
"Partner", partnerName(partners, row.getValueInteger("partnerId")), "Age", humanize(Duration.between(row.getValueInstant("createDate"), now))));
}
return (table(List.of("Exception", "Partner", "Age"), rows, "edi_exception", openExceptions(), "View all exceptions"));
}
/*******************************************************************************
** Overdue acknowledgements with partner names and humanized ages.
*******************************************************************************/
private static RenderWidgetOutput acks(Instant now) throws QException
{
Map<Integer, String> partners = partnerNames();
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord group : query("edi_functional_group", overdueAcks(now).withOrderBy(new QFilterOrderBy("ackDueAt", true)).withLimit(20)))
{
Integer partnerId = null;
Instant sent = null;
List<QRecord> interchanges = query("edi_interchange", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, group.getValue("interchangeId")));
if(!interchanges.isEmpty())
{
partnerId = interchanges.getFirst().getValueInteger("partnerId");
Serializable messageId = interchanges.getFirst().getValue("outboundMessageId");
if(messageId != null)
{
List<QRecord> messages = query("edi_outbound_message", new QQueryFilter().withCriteria("id", QCriteriaOperator.EQUALS, messageId));
sent = messages.isEmpty() ? null : messages.getFirst().getValueInstant("sentAt");
}
}
Instant due = group.getValueInstant("ackDueAt");
rows.add(row("Partner", partnerName(partners, partnerId), "Group", "GS " + group.getValueString("controlNumber"),
"Sent", sent == null ? "Not sent" : humanize(Duration.between(sent, now)) + " ago", "Overdue by", due == null ? "" : humanize(Duration.between(due, now))));
}
return (table(List.of("Partner", "Group", "Sent", "Overdue by"), rows, "edi_functional_group", overdueAcks(now), "View functional groups"));
}
/*******************************************************************************
** Exceptions raised in the last day, newest first.
*******************************************************************************/
private static RenderWidgetOutput errors(Instant now) throws QException
{
Map<Integer, String> partners = partnerNames();
List<Map<String, Object>> rows = new ArrayList<>();
for(QRecord row : query("edi_exception", lastDay(now).withOrderBy(new QFilterOrderBy("createDate", false)).withOrderBy(new QFilterOrderBy("id", false)).withLimit(20)))
{
rows.add(row("Error", plain(row.getValueString("type")) + ": " + row.getValueString("summary"),
"Partner", partnerName(partners, row.getValueInteger("partnerId")), "When", humanize(Duration.between(row.getValueInstant("createDate"), now)) + " ago"));
}
return (table(List.of("Error", "Partner", "When"), rows, "edi_exception", lastDay(now), "View exceptions"));
}
/*******************************************************************************
** Created within the last day.
*******************************************************************************/
private static QQueryFilter lastDay(Instant now)
{
return (new QQueryFilter().withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(1, ChronoUnit.DAYS)));
}
/*******************************************************************************
** Documents by partner and transaction set over seven days, with a total row.
*******************************************************************************/
private static RenderWidgetOutput volume(Instant now) throws QException
{
Map<Integer, String> partners = partnerNames();
QQueryFilter filter = new QQueryFilter().withCriteria("createDate", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(7, ChronoUnit.DAYS));
Map<List<String>, int[]> counts = new TreeMap<>((a, b) -> a.get(0).equals(b.get(0)) ? a.get(1).compareTo(b.get(1)) : a.get(0).compareTo(b.get(0)));
for(QRecord row : query("edi_transaction", filter))
{
int[] c = counts.computeIfAbsent(List.of(partnerName(partners, row.getValueInteger("partnerId")), documentName(row.getValueString("transactionSet"))), k -> new int[3]);
c["INBOUND".equals(row.getValueString("direction")) ? 0 : 1]++;
if(List.of("INVALID", "REJECTED").contains(row.getValueString("status")))
{
c[2]++;
}
}
List<Map<String, Object>> rows = new ArrayList<>();
int[] totals = new int[3];
for(Map.Entry<List<String>, int[]> entry : counts.entrySet())
{
int[] c = entry.getValue();
rows.add(row("Partner", entry.getKey().get(0), "Document", entry.getKey().get(1), "In / out / rejected", c[0] + " / " + c[1] + " / " + c[2]));
for(int i = 0; i < 3; i++)
{
totals[i] += c[i];
}
}
rows.add(row("Partner", "Total", "Document", "", "In / out / rejected", totals[0] + " / " + totals[1] + " / " + totals[2]));
return (table(List.of("Partner", "Document", "In / out / rejected"), rows, "edi_transaction", filter, "View documents"));
}
/*******************************************************************************
** Integration and queue state as one compact two-column table.
*******************************************************************************/
private static RenderWidgetOutput system(Instant now) throws QException
{
boolean secured = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getQBits().values().stream().anyMatch(q -> "qbit-secured-data".equals(q.getArtifactId()));
String store = EdiRuntime.current().config().getPayloadStore();
List<QRecord> oldestIn = query("edi_inbox", inboxBacklog().withOrderBy(new QFilterOrderBy("receivedAt", true)).withLimit(1));
List<QRecord> oldestOut = query("edi_outbound_message", outboundQueue().withOrderBy(new QFilterOrderBy("createDate", true)).withLimit(1));
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("Item", "Payload storage", "Status", "DATABASE".equals(store) ? "Database, encrypted (demos and tests)" : "Object storage, encrypted"));
rows.add(row("Item", "Secured data", "Status", secured ? "Installed" : "Not installed: PHI fields are not masked"));
rows.add(row("Item", "External code sets", "Status", "Not configured"));
rows.add(row("Item", "Inbound backlog", "Status", countOf("edi_inbox", inboxBacklog())
+ (oldestIn.isEmpty() ? "" : ", oldest " + humanize(Duration.between(oldestIn.getFirst().getValueInstant("receivedAt"), now)))));
rows.add(row("Item", "Outbound queue", "Status", countOf("edi_outbound_message", outboundQueue())
+ (oldestOut.isEmpty() ? "" : ", oldest " + humanize(Duration.between(oldestOut.getFirst().getValueInstant("createDate"), now)))));
return (table(List.of("Item", "Status"), rows, "edi_outbound_message", outboundQueue(), "View outbound queue"));
}
/*******************************************************************************
** Inbox rows not yet processed.
*******************************************************************************/
private static QQueryFilter inboxBacklog()
{
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "PENDING", "PROCESSING", "RETRY_WAIT"));
}
/*******************************************************************************
** Outbound messages waiting to send.
*******************************************************************************/
private static QQueryFilter outboundQueue()
{
return (new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT"));
}
/*******************************************************************************
** Partner names readable by the viewing user.
*******************************************************************************/
private static Map<Integer, String> partnerNames() throws QException
{
Map<Integer, String> names = new HashMap<>();
for(QRecord partner : query("edi_partner", new QQueryFilter()))
{
names.put(partner.getValueInteger("id"), partner.getValueString("name"));
}
return (names);
}
/*******************************************************************************
** A partner's name, or a plain placeholder.
*******************************************************************************/
private static String partnerName(Map<Integer, String> names, Integer id)
{
return (id == null ? "Unrouted" : names.getOrDefault(id, "Partner " + id));
}
/*******************************************************************************
** A transaction set with its plain name.
*******************************************************************************/
static String documentName(String transactionSet)
{
String name = DOCUMENT_NAMES.get(transactionSet);
return (name == null ? String.valueOf(transactionSet) : transactionSet + " " + name);
}
/*******************************************************************************
** UPPER_SNAKE enum value as a sentence-case label.
*******************************************************************************/
static String plain(String value)
{
if(value == null || value.isEmpty())
{
return ("");
}
String lower = value.toLowerCase().replace('_', ' ');
return (Character.toUpperCase(lower.charAt(0)) + lower.substring(1));
}
/*******************************************************************************
** Compact human duration: 45 s, 12 m, 3 h 5 m, 2 d 4 h.
*******************************************************************************/
static String humanize(Duration duration)
{
long seconds = Math.max(0L, duration.getSeconds());
if(seconds < 60)
{
return (seconds + " s");
}
long minutes = seconds / 60;
if(minutes < 60)
{
return (minutes + " m");
}
long hours = minutes / 60;
if(hours < 24)
{
return (hours + " h" + (minutes % 60 == 0 ? "" : " " + (minutes % 60) + " m"));
}
return (hours / 24 + " d" + (hours % 24 == 0 ? "" : " " + (hours % 24) + " h"));
}
/*******************************************************************************
** An ordered row from alternating keys and values.
*******************************************************************************/
private static Map<String, Object> row(Object... keysAndValues)
{
Map<String, Object> row = new LinkedHashMap<>();
for(int i = 0; i < keysAndValues.length; i += 2)
{
row.put(String.valueOf(keysAndValues[i]), keysAndValues[i + 1]);
}
return (row);
}
/*******************************************************************************
** A typed table with a link to the filtered table.
*******************************************************************************/
private static RenderWidgetOutput table(List<String> columns, List<Map<String, Object>> rows, String linkTable, QQueryFilter linkFilter, String linkText) throws QException
{
return (new RenderWidgetOutput(tableData(columns, rows).withLinkURL(link(linkTable, linkFilter)).withLinkText(linkText)));
}
/*******************************************************************************
** Plain cells allow typed rendering and never contain HTML.
*******************************************************************************/
private static TableData tableData(List<String> columns, List<Map<String, Object>> rows)
{
return (new TableData(null, columns.stream().map(c -> new TableData.Column("text", c, c, null, "left")).toList(), rows));
}
/*******************************************************************************
** Explicit permission enforcement is required inside widget renderers.
*******************************************************************************/
private static List<QRecord> query(String table, QQueryFilter filter) throws QException
{
QueryInput query = new QueryInput(table).withFilter(filter);
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
return (new QueryAction().execute(query).getRecords());
}
/*******************************************************************************
** Standard filtered-table URL used by statistic and table navigation.
*******************************************************************************/
private static String link(String table, QQueryFilter filter) throws QException
{
try
{
return ("/app/" + table + "?filter=" + URLEncoder.encode(new ObjectMapper().findAndRegisterModules().writeValueAsString(filter), StandardCharsets.UTF_8));
}
catch(Exception e)
{
throw new QException("Could not create EDI table link");
}
}
}
@@ -1,646 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.util.Arrays;
import java.util.Date;
import java.util.List;
import com.helger.as2lib.crypto.BCCryptoHelper;
import com.helger.as2lib.crypto.ECryptoAlgorithmCrypt;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.helger.mail.cte.EContentTransferEncoding;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import jakarta.activation.DataHandler;
import jakarta.mail.internet.ContentType;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.util.ByteArrayDataSource;
import org.bouncycastle.cms.CMSEnvelopedData;
import org.bouncycastle.cms.CMSEnvelopedDataGenerator;
import org.bouncycastle.cms.CMSProcessableByteArray;
import org.bouncycastle.cms.CMSSignerDigestMismatchException;
import org.bouncycastle.cms.RecipientInformation;
import org.bouncycastle.cms.SignerInformation;
import org.bouncycastle.cms.jcajce.JcaSimpleSignerInfoVerifierBuilder;
import org.bouncycastle.cms.jcajce.JceCMSContentEncryptorBuilder;
import org.bouncycastle.cms.jcajce.JceKeyTransEnvelopedRecipient;
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientId;
import org.bouncycastle.cms.jcajce.JceKeyTransRecipientInfoGenerator;
import org.bouncycastle.cms.jcajce.ZlibCompressor;
import org.bouncycastle.cms.jcajce.ZlibExpanderProvider;
import org.bouncycastle.mail.smime.SMIMECompressed;
import org.bouncycastle.mail.smime.SMIMECompressedGenerator;
import org.bouncycastle.mail.smime.SMIMEEnveloped;
import org.bouncycastle.mail.smime.SMIMESigned;
/*******************************************************************************
** The AS2 cryptographic layer, entirely in memory. S/MIME signing and the
** algorithm tables use as2-lib's BouncyCastle helper; encryption, decryption,
** signature verification, compression and the MIC use BouncyCastle directly,
** because as2-lib's decrypt and verify stage content in temporary files and this
** qbit never writes local files. The MIC is computed over the exact bytes of the
** signed (or, when unsigned, the transmitted) MIME entity, headers included, so
** both ends agree whatever transfer encoding was used. Messages follow RFC 4130
** (AS2), RFC 5751 (S/MIME) and RFC 5402 (compression).
*******************************************************************************/
public final class As2Crypto
{
private static final BCCryptoHelper HELPER = new BCCryptoHelper();
private static final String PROVIDER = BCCryptoHelper.DEFAULT_SECURITY_PROVIDER_NAME;
private static final byte[] HEADER_END = "\r\n\r\n".getBytes(StandardCharsets.US_ASCII);
private static final byte[] LINE_END = "\r\n".getBytes(StandardCharsets.US_ASCII);
public static final long MAX_EXPANDED_BYTES = 256L * 1024 * 1024;
/*******************************************************************************
** The result of unwrapping an inbound message: the EDI payload, what protection
** was applied and the MIC of the signed (or received) content.
*******************************************************************************/
public record Decoded(byte[] payload, String fileName, Boolean encrypted, Boolean signed, Boolean compressed, MIC mic)
{
}
/*******************************************************************************
** A MIME entity split for HTTP: the Content-Type, transfer encoding and
** disposition go in HTTP headers, the body is the HTTP body.
*******************************************************************************/
public record Wire(String contentType, String transferEncoding, String contentDisposition, byte[] body)
{
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Crypto()
{
}
/*******************************************************************************
** Map the signAlgorithm setting; null means unsigned.
*******************************************************************************/
public static ECryptoAlgorithmSign signAlgorithm(String setting)
{
if(setting == null || setting.isBlank())
{
return (null);
}
return (switch(setting)
{
case "SHA256" -> ECryptoAlgorithmSign.DIGEST_SHA_256;
case "SHA384" -> ECryptoAlgorithmSign.DIGEST_SHA_384;
case "SHA512" -> ECryptoAlgorithmSign.DIGEST_SHA_512;
case "SHA1" -> ECryptoAlgorithmSign.DIGEST_SHA_1;
default -> throw new IllegalArgumentException("Unsupported signAlgorithm");
});
}
/*******************************************************************************
** Map the encryptAlgorithm setting; null means unencrypted.
*******************************************************************************/
public static ECryptoAlgorithmCrypt encryptAlgorithm(String setting)
{
if(setting == null || setting.isBlank())
{
return (null);
}
return (switch(setting)
{
case "AES128_CBC" -> ECryptoAlgorithmCrypt.CRYPT_AES128_CBC;
case "AES256_CBC" -> ECryptoAlgorithmCrypt.CRYPT_AES256_CBC;
case "AES128_GCM" -> ECryptoAlgorithmCrypt.CRYPT_AES128_GCM;
case "AES256_GCM" -> ECryptoAlgorithmCrypt.CRYPT_AES256_GCM;
case "3DES" -> ECryptoAlgorithmCrypt.CRYPT_3DES;
default -> throw new IllegalArgumentException("Unsupported encryptAlgorithm");
});
}
/*******************************************************************************
** SHA1 signatures and 3DES encryption are accepted only when set explicitly,
** and are flagged as weak.
*******************************************************************************/
public static boolean isWeak(String sign, String encrypt)
{
return ("SHA1".equals(sign) || "3DES".equals(encrypt));
}
/*******************************************************************************
** Parse a complete MIME entity (headers, blank line, body) held in memory.
*******************************************************************************/
public static MimeBodyPart parse(byte[] entity) throws As2Exception
{
try
{
return (new MimeBodyPart(new ByteArrayInputStream(entity)));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message is not a valid MIME entity", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Rebuild the MIME entity of an HTTP request or response from its Content-Type,
** Content-Transfer-Encoding and Content-Disposition headers and its body.
*******************************************************************************/
public static MimeBodyPart fromHttp(String contentType, String transferEncoding, String contentDisposition, byte[] body) throws As2Exception
{
return (parse(entity(contentType, transferEncoding, contentDisposition, body)));
}
/*******************************************************************************
** The MIME entity bytes (headers, blank line, body) for HTTP-level parts. Header
** values with line breaks are refused.
*******************************************************************************/
public static byte[] entity(String contentType, String transferEncoding, String contentDisposition, byte[] body) throws As2Exception
{
for(String value : Arrays.asList(contentType, transferEncoding, contentDisposition))
{
if(value != null && (value.contains("\r") || value.contains("\n")))
{
throw (new As2Exception("The AS2 message has an invalid header", As2Exception.UNEXPECTED_PROCESSING));
}
}
if(contentType == null || contentType.isBlank())
{
throw (new As2Exception("The AS2 message has no valid Content-Type", As2Exception.UNEXPECTED_PROCESSING));
}
ByteArrayOutputStream entity = new ByteArrayOutputStream();
String headers = "Content-Type: " + contentType + "\r\n" + (transferEncoding == null || transferEncoding.isBlank() ? "" : "Content-Transfer-Encoding: " + transferEncoding + "\r\n")
+ (contentDisposition == null || contentDisposition.isBlank() ? "" : "Content-Disposition: " + contentDisposition + "\r\n") + "\r\n";
entity.writeBytes(headers.getBytes(StandardCharsets.ISO_8859_1));
entity.writeBytes(body);
return (entity.toByteArray());
}
/*******************************************************************************
** Serialize a MIME entity (headers and encoded body).
*******************************************************************************/
public static byte[] toBytes(MimeBodyPart part) throws As2Exception
{
try
{
ByteArrayOutputStream out = new ByteArrayOutputStream();
part.writeTo(out);
return (out.toByteArray());
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be serialized", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Split a MIME entity into the HTTP headers that describe it and its body.
*******************************************************************************/
public static Wire toWire(MimeBodyPart part) throws As2Exception
{
return (splitRaw(toBytes(part)));
}
/*******************************************************************************
** Split already serialized MIME entity bytes into the Content-Type,
** Content-Transfer-Encoding and Content-Disposition values and the body,
** without re-serializing (which could invalidate a signature).
*******************************************************************************/
public static Wire splitRaw(byte[] bytes) throws As2Exception
{
int split = indexOf(bytes, HEADER_END, 0);
if(split < 0)
{
throw (new As2Exception("The AS2 message has no body", As2Exception.UNEXPECTED_PROCESSING));
}
String contentType = null;
String encoding = null;
String disposition = null;
String current = null;
StringBuilder value = new StringBuilder();
for(String line : (new String(bytes, 0, split, StandardCharsets.ISO_8859_1) + "\r\nX-End: end").split("\r\n"))
{
if((line.startsWith(" ") || line.startsWith("\t")) && current != null)
{
value.append(' ').append(line.trim());
continue;
}
if(current != null)
{
contentType = "content-type".equals(current) ? value.toString() : contentType;
encoding = "content-transfer-encoding".equals(current) ? value.toString() : encoding;
disposition = "content-disposition".equals(current) ? value.toString() : disposition;
}
int colon = line.indexOf(':');
current = colon < 0 ? null : line.substring(0, colon).trim().toLowerCase(java.util.Locale.ROOT);
value = new StringBuilder(colon < 0 ? "" : line.substring(colon + 1).trim());
}
byte[] body = new byte[bytes.length - split - HEADER_END.length];
System.arraycopy(bytes, split + HEADER_END.length, body, 0, body.length);
return (new Wire(contentType, encoding, disposition, body));
}
/*******************************************************************************
** The EDI document as a binary MIME part.
*******************************************************************************/
public static MimeBodyPart payloadPart(byte[] payload, String contentType, String fileName) throws As2Exception
{
try
{
MimeBodyPart part = new MimeBodyPart();
part.setDataHandler(new DataHandler(new ByteArrayDataSource(payload, contentType)));
part.setHeader("Content-Type", contentType);
part.setHeader("Content-Transfer-Encoding", "binary");
part.setHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
return (part);
}
catch(Exception e)
{
throw (new As2Exception("The EDI document could not be wrapped for AS2", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Compress a part (RFC 5402, zlib).
*******************************************************************************/
public static MimeBodyPart compress(MimeBodyPart part) throws As2Exception
{
try
{
SMIMECompressedGenerator generator = new SMIMECompressedGenerator();
generator.setContentTransferEncoding("binary");
return (generator.generate(part, new ZlibCompressor()));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be compressed", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** The MIC of a MIME entity: the digest of its exact bytes, headers included
** (RFC 4130 section 7.3.1).
*******************************************************************************/
public static MIC mic(byte[] entity, ECryptoAlgorithmSign algorithm) throws As2Exception
{
try
{
return (new MIC(MessageDigest.getInstance(algorithm.getID().toUpperCase(java.util.Locale.ROOT)).digest(entity), algorithm));
}
catch(Exception e)
{
throw (new As2Exception("The message integrity check could not be computed", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** The exact bytes of the first body part of a multipart entity: the signed
** content of multipart/signed, without the delimiter lines around it.
*******************************************************************************/
public static byte[] firstPart(String contentType, byte[] body) throws As2Exception
{
try
{
String boundary = new ContentType(contentType).getParameter("boundary");
int open = indexOf(body, ("--" + boundary).getBytes(StandardCharsets.ISO_8859_1), 0);
int start = open < 0 ? -1 : indexOf(body, LINE_END, open) + LINE_END.length;
int end = start < 0 ? -1 : indexOf(body, ("\r\n--" + boundary).getBytes(StandardCharsets.ISO_8859_1), start - LINE_END.length);
if(end < 0)
{
throw (new IllegalArgumentException("no signed content"));
}
return (Arrays.copyOfRange(body, start, end));
}
catch(Exception e)
{
throw (new As2Exception("The signed AS2 message is malformed", As2Exception.AUTHENTICATION_FAILED));
}
}
/*******************************************************************************
** Sign a part into multipart/signed with the local key.
*******************************************************************************/
public static MimeBodyPart sign(MimeBodyPart part, LocalKey key, ECryptoAlgorithmSign algorithm) throws As2Exception
{
try
{
return (HELPER.sign(part, key.certificate(), key.key(), algorithm, true, algorithm == ECryptoAlgorithmSign.DIGEST_SHA_1, false, EContentTransferEncoding.BINARY));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be signed with certificate " + key.row().getValueString("alias") + ": " + e.getClass().getSimpleName(), As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Encrypt the exact bytes of a MIME entity to the partner certificate (CMS
** enveloped data), returning the application/pkcs7-mime wire message.
*******************************************************************************/
public static Wire encrypt(byte[] entity, X509Certificate certificate, ECryptoAlgorithmCrypt algorithm) throws As2Exception
{
try
{
CMSEnvelopedDataGenerator generator = new CMSEnvelopedDataGenerator();
generator.addRecipientInfoGenerator(new JceKeyTransRecipientInfoGenerator(certificate).setProvider(PROVIDER));
CMSEnvelopedData enveloped = generator.generate(new CMSProcessableByteArray(entity), new JceCMSContentEncryptorBuilder(algorithm.getOID()).setProvider(PROVIDER).build());
return (new Wire("application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m", "binary", "attachment; filename=\"smime.p7m\"", enveloped.getEncoded()));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 message could not be encrypted: " + e.getClass().getSimpleName(), As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Unwrap an inbound entity: decrypt with any of our keys, verify the signature
** against the partner certificates, decompress, and compute the MIC over the
** exact signed bytes (or, when unsigned, the entity after decryption). Layers
** may nest in any order (RFC 5402 allows compression before or after signing).
*******************************************************************************/
public static Decoded decode(byte[] entity, List<LocalKey> keys, List<X509Certificate> partnerCertificates, ECryptoAlgorithmSign micAlgorithm, Date now) throws As2Exception
{
byte[] raw = entity;
byte[] micBytes = raw;
MimeBodyPart part = parse(raw);
boolean encrypted = false;
boolean signed = false;
boolean compressed = false;
for(int depth = 0; depth < 6; depth++)
{
if(isEncrypted(part))
{
raw = decrypt(part, keys);
part = parse(raw);
micBytes = signed ? micBytes : raw;
encrypted = true;
}
else if(isSigned(part))
{
verify(part, partnerCertificates, now);
Wire wire = splitRaw(raw);
raw = firstPart(wire.contentType(), wire.body());
micBytes = raw;
part = parse(raw);
signed = true;
}
else if(isCompressed(part))
{
raw = expand(part);
part = parse(raw);
compressed = true;
}
else
{
break;
}
}
try
{
MIC mic = micAlgorithm == null ? null : mic(micBytes, micAlgorithm);
return (new Decoded(part.getInputStream().readAllBytes(), part.getFileName(), encrypted, signed, compressed, mic));
}
catch(As2Exception e)
{
throw (e);
}
catch(Exception e)
{
throw (new As2Exception("The AS2 payload could not be read", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Verify a multipart/signed entity against the candidate certificates and
** return the signed content. A digest that does not match the content is an
** integrity failure; a signature no candidate certificate verifies is an
** authentication failure. Expired certificates are not accepted.
*******************************************************************************/
public static MimeBodyPart verify(MimeBodyPart part, List<X509Certificate> certificates, Date now) throws As2Exception
{
SMIMESigned signed;
try
{
signed = new SMIMESigned(new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))));
}
catch(Exception e)
{
throw (new As2Exception("The signed AS2 message is malformed", As2Exception.AUTHENTICATION_FAILED));
}
for(SignerInformation signer : signed.getSignerInfos().getSigners())
{
for(X509Certificate certificate : certificates)
{
if(certificate.getNotAfter().before(now))
{
continue;
}
try
{
if(signer.verify(new JcaSimpleSignerInfoVerifierBuilder().setProvider(PROVIDER).build(certificate)))
{
return (signed.getContent());
}
}
catch(CMSSignerDigestMismatchException e)
{
throw (new As2Exception("The message digest does not match the signed content", As2Exception.INTEGRITY_CHECK_FAILED));
}
catch(Exception e)
{
// This certificate cannot verify the signature; try the next.
continue;
}
}
}
throw (new As2Exception("The signature could not be verified with the partner certificates", As2Exception.AUTHENTICATION_FAILED));
}
/*******************************************************************************
** Decrypt with the first of our keys the message was encrypted to, returning
** the clear MIME entity bytes.
*******************************************************************************/
private static byte[] decrypt(MimeBodyPart part, List<LocalKey> keys) throws As2Exception
{
SMIMEEnveloped enveloped;
try
{
enveloped = new SMIMEEnveloped(part);
}
catch(Exception e)
{
throw (new As2Exception("The encrypted AS2 message is malformed", As2Exception.DECRYPTION_FAILED));
}
for(LocalKey key : keys)
{
try
{
RecipientInformation recipient = enveloped.getRecipientInfos().get(new JceKeyTransRecipientId(key.certificate()));
if(recipient != null)
{
return (recipient.getContent(new JceKeyTransEnvelopedRecipient(key.key()).setProvider(PROVIDER)));
}
}
catch(Exception e)
{
// The key matched the recipient but could not decrypt; try the next key.
continue;
}
}
throw (new As2Exception("The message was not encrypted to an accepted certificate", As2Exception.DECRYPTION_FAILED));
}
/*******************************************************************************
** Inflate a compressed-data entity, bounded to prevent decompression bombs.
*******************************************************************************/
private static byte[] expand(MimeBodyPart part) throws As2Exception
{
try
{
return (new SMIMECompressed(part).getContent(new ZlibExpanderProvider(MAX_EXPANDED_BYTES)));
}
catch(Exception e)
{
throw (new As2Exception("The compressed AS2 message could not be expanded", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** True for application/pkcs7-mime enveloped-data.
*******************************************************************************/
private static boolean isEncrypted(MimeBodyPart part) throws As2Exception
{
try
{
return (HELPER.isEncrypted(part));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** True for multipart/signed.
*******************************************************************************/
private static boolean isSigned(MimeBodyPart part) throws As2Exception
{
try
{
return (HELPER.isSigned(part));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** True for application/pkcs7-mime compressed-data.
*******************************************************************************/
private static boolean isCompressed(MimeBodyPart part) throws As2Exception
{
try
{
return (HELPER.isCompressed(part.getContentType()));
}
catch(Exception e)
{
throw (new As2Exception("The AS2 Content-Type is invalid", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Index of a byte sequence at or after a position, or -1.
*******************************************************************************/
private static int indexOf(byte[] data, byte[] pattern, int from)
{
for(int i = Math.max(from, 0); i <= data.length - pattern.length; i++)
{
int j = 0;
while(j < pattern.length && data[i + j] == pattern[j])
{
j++;
}
if(j == pattern.length)
{
return (i);
}
}
return (-1);
}
}
@@ -1,69 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
/*******************************************************************************
** A plain, value-free AS2 failure. The message never carries key material,
** secrets or payload content; the optional code is the RFC 4130 disposition
** modifier a receiver reports to the sender (for example authentication-failed).
*******************************************************************************/
public class As2Exception extends Exception
{
public static final String DECRYPTION_FAILED = "decryption-failed";
public static final String AUTHENTICATION_FAILED = "authentication-failed";
public static final String INTEGRITY_CHECK_FAILED = "integrity-check-failed";
public static final String INSUFFICIENT_SECURITY = "insufficient-message-security";
public static final String UNEXPECTED_PROCESSING = "unexpected-processing-error";
private final String code;
/*******************************************************************************
** Failure without a disposition code (configuration and local problems).
*******************************************************************************/
public As2Exception(String message)
{
this(message, null);
}
/*******************************************************************************
** Failure carrying the RFC 4130 disposition modifier.
*******************************************************************************/
public As2Exception(String message, String code)
{
super(message);
this.code = code;
}
/*******************************************************************************
** The RFC 4130 disposition modifier, or null when the failure is local.
*******************************************************************************/
public String getCode()
{
return (code);
}
}
@@ -1,234 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import javax.net.ssl.SSLContext;
import javax.net.ssl.TrustManager;
import javax.net.ssl.X509TrustManager;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.util.Base64;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
/*******************************************************************************
** HTTP client helpers for AS2: bounded timeouts, no redirects, optional pinning
** of the partner TLS certificate (otherwise the JVM trust store applies), and
** bounded response bodies.
*******************************************************************************/
public final class As2Http
{
public static final int MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
/*******************************************************************************
** Status, lower-case header names and the (bounded) body of a response.
*******************************************************************************/
public record Response(int status, Map<String, String> headers, byte[] body)
{
}
/*******************************************************************************
** Accepts only the pinned certificate as the server leaf; the hostname is
** still checked by the TLS stack.
*******************************************************************************/
private static final class PinnedTrustManager implements X509TrustManager
{
private final byte[] pinned;
/*******************************************************************************
** Pin the SHA-256 of the encoded certificate.
*******************************************************************************/
private PinnedTrustManager(X509Certificate certificate) throws Exception
{
pinned = MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded());
}
/*******************************************************************************
** This client never authenticates with a client certificate.
*******************************************************************************/
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException
{
throw (new CertificateException("Client certificates are not accepted"));
}
/*******************************************************************************
** Require the leaf to be exactly the pinned certificate.
*******************************************************************************/
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException
{
try
{
if(chain == null || chain.length == 0 || !MessageDigest.isEqual(pinned, MessageDigest.getInstance("SHA-256").digest(chain[0].getEncoded())))
{
throw (new CertificateException("The server certificate does not match the pinned partner TLS certificate"));
}
}
catch(CertificateException e)
{
throw (e);
}
catch(Exception e)
{
throw (new CertificateException("The server certificate could not be checked against the pin"));
}
}
/*******************************************************************************
** No trust anchors beyond the pin.
*******************************************************************************/
@Override
public X509Certificate[] getAcceptedIssuers()
{
return (new X509Certificate[0]);
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Http()
{
}
/*******************************************************************************
** A client using the JVM trust store, or only the pinned certificate.
*******************************************************************************/
public static HttpClient client(X509Certificate pinned) throws Exception
{
HttpClient.Builder builder = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).followRedirects(HttpClient.Redirect.NEVER);
if(pinned != null)
{
SSLContext context = SSLContext.getInstance("TLS");
context.init(null, new TrustManager[] {new PinnedTrustManager(pinned)}, new SecureRandom());
builder.sslContext(context);
}
return (builder.build());
}
/*******************************************************************************
** Trust manager for pin checks in tests and diagnostics.
*******************************************************************************/
public static X509TrustManager pinnedTrustManager(X509Certificate pinned) throws Exception
{
return (new PinnedTrustManager(pinned));
}
/*******************************************************************************
** POST bytes with the given headers.
*******************************************************************************/
public static Response post(HttpClient client, URI uri, Map<String, String> headers, byte[] body, Duration timeout) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(timeout).POST(HttpRequest.BodyPublishers.ofByteArray(body));
headers.forEach(request::header);
return (read(client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream())));
}
/*******************************************************************************
** OPTIONS probe: no body is sent.
*******************************************************************************/
public static Response options(HttpClient client, URI uri, Map<String, String> headers, Duration timeout) throws Exception
{
HttpRequest.Builder request = HttpRequest.newBuilder(uri).timeout(timeout).method("OPTIONS", HttpRequest.BodyPublishers.noBody());
headers.forEach(request::header);
return (read(client.send(request.build(), HttpResponse.BodyHandlers.ofInputStream())));
}
/*******************************************************************************
** Read the bounded body and lower-case the header names.
*******************************************************************************/
private static Response read(HttpResponse<InputStream> response) throws Exception
{
try(InputStream stream = response.body())
{
Map<String, String> headers = new TreeMap<>();
response.headers().map().forEach((name, values) -> headers.put(name.toLowerCase(Locale.ROOT), values.getFirst()));
return (new Response(response.statusCode(), headers, stream.readNBytes(MAX_RESPONSE_BYTES)));
}
}
/*******************************************************************************
** An Authorization header value for HTTP Basic authentication.
*******************************************************************************/
public static String basic(String user, String password)
{
return ("Basic " + Base64.getEncoder().encodeToString((user + ":" + password).getBytes(StandardCharsets.UTF_8)));
}
/*******************************************************************************
** TLS is required except on loopback, which the tests and local peers use.
*******************************************************************************/
public static boolean isAllowedUrl(String url)
{
try
{
URI uri = URI.create(url);
return (uri.getHost() != null && uri.getUserInfo() == null
&& ("https".equals(uri.getScheme()) || "http".equals(uri.getScheme()) && List.of("127.0.0.1", "localhost", "[::1]").contains(uri.getHost())));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -1,261 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.List;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import jakarta.mail.internet.InternetHeaders;
import jakarta.mail.internet.MimeBodyPart;
import jakarta.mail.internet.MimeMultipart;
import jakarta.mail.internet.MimeUtility;
import jakarta.mail.util.ByteArrayDataSource;
/*******************************************************************************
** Builds and parses AS2 message disposition notifications (RFC 4130 section 7,
** RFC 8098): multipart/report with a message/disposition-notification part,
** optionally wrapped in multipart/signed. Memory only.
*******************************************************************************/
public final class As2Mdn
{
public static final String PROCESSED = "automatic-action/MDN-sent-automatically; processed";
/*******************************************************************************
** A receipt ready to send or store: HTTP-level parts and the complete MIME
** entity (headers and body) kept as the stored raw MDN.
*******************************************************************************/
public record Built(String messageId, As2Crypto.Wire wire, byte[] entity, Boolean signed)
{
}
/*******************************************************************************
** A receipt read from a partner. Fields come from the disposition notification;
** signed and signatureValid describe the signature, when there was one.
*******************************************************************************/
public record Parsed(String originalMessageId, String disposition, String micText, MIC mic, Boolean signed, Boolean signatureValid)
{
/*******************************************************************************
** Success means the partner processed the message (a warning still counts).
*******************************************************************************/
public boolean processed()
{
if(disposition == null)
{
return (false);
}
int separator = disposition.indexOf(';');
String status = (separator < 0 ? disposition : disposition.substring(separator + 1)).trim().toLowerCase(java.util.Locale.ROOT);
return (status.equals("processed") || status.startsWith("processed/warning"));
}
}
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Mdn()
{
}
/*******************************************************************************
** Disposition text for a failure, e.g. processed/error: authentication-failed.
*******************************************************************************/
public static String failure(String code)
{
return ("automatic-action/MDN-sent-automatically; processed/error: " + code);
}
/*******************************************************************************
** Normalize an AS2 identifier or Message-ID: trim, drop angle brackets and
** quotes.
*******************************************************************************/
public static String normalizeId(String value)
{
if(value == null)
{
return (null);
}
String id = value.trim();
while(id.length() >= 2 && ((id.startsWith("<") && id.endsWith(">")) || (id.startsWith("\"") && id.endsWith("\""))))
{
id = id.substring(1, id.length() - 1).trim();
}
return (id);
}
/*******************************************************************************
** Keep only characters that are safe to show and store from a remote string.
*******************************************************************************/
public static String sanitize(String value)
{
if(value == null)
{
return (null);
}
String clean = value.replaceAll("[^A-Za-z0-9 /;:._,=+@<>-]", "");
return (clean.length() <= 200 ? clean : clean.substring(0, 200));
}
/*******************************************************************************
** Build a receipt. The MIC is included for processed messages; a signing key
** and algorithm produce a signed receipt.
*******************************************************************************/
public static Built build(String messageId, String ourId, String originalMessageId, String disposition, MIC mic, LocalKey key, ECryptoAlgorithmSign algorithm) throws As2Exception
{
try
{
String fields = "Reporting-UA: qbit-edi\r\nOriginal-Recipient: rfc822; " + ourId + "\r\nFinal-Recipient: rfc822; " + ourId
+ "\r\nOriginal-Message-ID: <" + originalMessageId + ">\r\nDisposition: " + disposition + "\r\n"
+ (mic == null ? "" : "Received-Content-MIC: " + mic.getAsAS2String() + "\r\n");
MimeMultipart report = new MimeMultipart("report; report-type=disposition-notification");
MimeBodyPart text = new MimeBodyPart();
text.setText("The AS2 message " + originalMessageId + " was processed with disposition: " + disposition + "\r\n", "us-ascii");
text.setHeader("Content-Transfer-Encoding", "7bit");
report.addBodyPart(text);
InternetHeaders headers = new InternetHeaders();
headers.addHeader("Content-Type", "message/disposition-notification");
headers.addHeader("Content-Transfer-Encoding", "7bit");
report.addBodyPart(new MimeBodyPart(headers, fields.getBytes(StandardCharsets.US_ASCII)));
MimeBodyPart outer = new MimeBodyPart();
outer.setContent(report);
outer.setHeader("Content-Type", report.getContentType());
MimeBodyPart result = key == null ? outer : As2Crypto.sign(outer, key, algorithm);
byte[] entity = As2Crypto.toBytes(result);
return (new Built(messageId, As2Crypto.splitRaw(entity), entity, key != null));
}
catch(As2Exception e)
{
throw (e);
}
catch(Exception e)
{
throw (new As2Exception("The MDN could not be built", As2Exception.UNEXPECTED_PROCESSING));
}
}
/*******************************************************************************
** Parse a receipt. A signature is verified against the candidate certificates
** and reported in signatureValid; an invalid one still yields the readable
** content so the failure can be recorded, but callers must not trust it.
*******************************************************************************/
public static Parsed parse(String contentType, String transferEncoding, byte[] body, List<X509Certificate> certificates, Date now) throws As2Exception
{
MimeBodyPart part = As2Crypto.fromHttp(contentType, transferEncoding, null, body);
boolean signed = false;
Boolean valid = null;
try
{
MimeBodyPart content = part;
if(part.isMimeType("multipart/signed"))
{
signed = true;
try
{
content = As2Crypto.verify(part, certificates, now);
valid = true;
}
catch(As2Exception e)
{
valid = false;
content = (MimeBodyPart) new MimeMultipart(new ByteArrayDataSource(part.getRawInputStream().readAllBytes(), MimeUtility.unfold(part.getContentType()))).getBodyPart(0);
}
}
if(!content.isMimeType("multipart/report"))
{
throw (new As2Exception("The response is not a message disposition notification"));
}
MimeMultipart report = new MimeMultipart(new ByteArrayDataSource(content.getRawInputStream().readAllBytes(), MimeUtility.unfold(content.getContentType())));
for(int i = 0; i < report.getCount(); i++)
{
if(report.getBodyPart(i).isMimeType("message/disposition-notification"))
{
InternetHeaders fields = new InternetHeaders(new ByteArrayInputStream(report.getBodyPart(i).getInputStream().readAllBytes()));
return (fromFields(fields, signed, valid));
}
}
throw (new As2Exception("The response has no disposition notification"));
}
catch(As2Exception e)
{
throw (e);
}
catch(Exception e)
{
throw (new As2Exception("The response is not a readable message disposition notification"));
}
}
/*******************************************************************************
** Read the notification fields.
*******************************************************************************/
private static Parsed fromFields(InternetHeaders fields, boolean signed, Boolean valid)
{
String original = normalizeId(first(fields, "Original-Message-ID"));
String disposition = sanitize(first(fields, "Disposition"));
String micText = sanitize(first(fields, "Received-Content-MIC"));
MIC mic = null;
try
{
mic = MIC.parse(micText);
}
catch(Exception e)
{
// An unreadable MIC is reported as a mismatch by the caller.
mic = null;
}
return (new Parsed(original == null ? null : sanitize(original), disposition, micText, mic, signed, valid));
}
/*******************************************************************************
** First value of a notification field, unfolded, or null.
*******************************************************************************/
private static String first(InternetHeaders fields, String name)
{
String[] values = fields.getHeader(name);
return (values == null || values.length == 0 ? null : MimeUtility.unfold(values[0]).trim());
}
}
@@ -1,163 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.security.cert.X509Certificate;
import java.util.Date;
import java.util.List;
import java.util.Map;
import com.helger.as2lib.crypto.MIC;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.flow.EdiJournal;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Receives asynchronous MDNs for messages we sent: parses the receipt, verifies
** its signature against the partner certificates, matches Original-Message-ID to
** an outbound message, compares the MIC, then marks the message DELIVERED or
** FAILED, stores the raw MDN, writes a timeline event and resolves the overdue
** alert. Callers run inside the system context.
*******************************************************************************/
public class As2MdnReceiver
{
private static final QLogger LOG = QLogger.getLogger(As2MdnReceiver.class);
/*******************************************************************************
** Handle one inbound MDN; header names are lower case. A receipt that cannot be
** authenticated changes nothing.
*******************************************************************************/
public As2Receiver.Result receive(EdiTransport transport, Map<String, String> headers, byte[] body) throws Exception
{
String from = As2Mdn.normalizeId(headers.get("as2-from"));
String to = As2Mdn.normalizeId(headers.get("as2-to"));
if(from == null || to == null || !from.equalsIgnoreCase(transport.getAs2PartnerId()) || !to.equalsIgnoreCase(transport.getAs2LocalId()))
{
LOG.warn("AS2 MDN refused for transport " + transport.getName() + ": unknown AS2 identity");
return (As2Receiver.Result.text(403, "Unknown AS2 identity"));
}
As2Mdn.Parsed mdn;
try
{
List<X509Certificate> certificates = transport.getPartnerCertificateId() == null ? List.of() : new CertificateService().partnerVerificationCertificates(transport);
mdn = As2Mdn.parse(headers.get("content-type"), headers.get("content-transfer-encoding"), body, certificates, Date.from(EdiRuntime.current().config().getClock().instant()));
}
catch(As2Exception e)
{
return (As2Receiver.Result.text(400, "The MDN could not be read"));
}
if((Boolean.TRUE.equals(mdn.signed()) && !Boolean.TRUE.equals(mdn.signatureValid())) || (Boolean.TRUE.equals(transport.getMdnSigned()) && !Boolean.TRUE.equals(mdn.signed())))
{
LOG.warn("AS2 MDN refused for transport " + transport.getName() + ": signature check failed");
return (As2Receiver.Result.text(401, "The MDN signature could not be verified"));
}
List<QRecord> messages = mdn.originalMessageId() == null ? List.of() : EdiRecords.query("edi_outbound_message", EdiRecords.equal("transportMessageId", mdn.originalMessageId())
.withCriteria("transportId", QCriteriaOperator.EQUALS, transport.getId()));
if(messages.isEmpty())
{
return (As2Receiver.Result.text(404, "No message was sent with that Original-Message-ID"));
}
String mdnId = As2Mdn.sanitize(As2Mdn.normalizeId(headers.get("message-id")));
if(mdnId != null && !EdiRecords.query(As2Store.TABLE, EdiRecords.equal("direction", "INBOUND").withCriteria("messageId", QCriteriaOperator.EQUALS, mdnId)
.withCriteria("originalMessageId", QCriteriaOperator.EQUALS, mdn.originalMessageId())).isEmpty())
{
return (new As2Receiver.Result(200, Map.of(), new byte[0]));
}
settle(transport, messages.getFirst(), mdn, mdnId, headers, body);
return (new As2Receiver.Result(200, Map.of(), new byte[0]));
}
/*******************************************************************************
** Record the receipt and settle the message it answers.
*******************************************************************************/
private void settle(EdiTransport transport, QRecord message, As2Mdn.Parsed mdn, String mdnId, Map<String, String> headers, byte[] body) throws Exception
{
MIC ours = null;
try
{
ours = MIC.parse(message.getValueString("transportMic"));
}
catch(Exception e)
{
LOG.warn("The MIC stored for outbound message " + message.getValue("id") + " could not be read");
}
String failure = As2Transport.judge(transport, mdn, ours);
byte[] raw = As2Crypto.entity(headers.get("content-type"), headers.get("content-transfer-encoding"), null, body);
SystemWrites.transaction(() ->
{
PayloadRef ref = As2Store.storeRaw(raw);
As2Store.insert(new QRecord().withValue("direction", "INBOUND").withValue("messageId", mdnId).withValue("originalMessageId", mdn.originalMessageId())
.withValue("disposition", mdn.disposition()).withValue("micAlgorithm", mdn.mic() == null ? null : mdn.mic().getDigestAlgorithm().getID())
.withValue("micValue", As2Store.micValue(mdn.mic())).withValue("signed", mdn.signed()).withValue("signatureValid", mdn.signatureValid()).withValue("payloadRef", ref.value())
.withValue("transportId", transport.getId()).withValue("partnerId", message.getValue("partnerId")).withValue("outboundMessageId", message.getValue("id")).withValue("deliveryStatus", "NONE"));
Integer interchangeId = message.getValueInteger("interchangeId");
EdiJournal.event(null, interchangeId, null, "MDN");
if(SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status='SENT'", message.getValue("id")))
{
var now = EdiRuntime.current().config().getClock().instant();
String status = failure == null ? "DELIVERED" : "FAILED";
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("status", status).withValue("lastError", failure == null ? null : truncate(failure));
if(failure == null)
{
update.withValue("deliveredAt", now);
}
SystemWrites.update("edi_outbound_message", update);
if(interchangeId != null)
{
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchangeId).withValue("status", status));
}
EdiJournal.event(null, interchangeId, null, status);
if(failure != null)
{
EdiJournal.exception(null, interchangeId, null, message.getValueInteger("partnerId"), "TRANSPORT", "Outbound delivery failed");
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_outbound_message:" + message.getValue("id"))
.withTitle("EDI delivery failed").withSummary("Review the outbound message"));
}
}
return (null);
});
QAlerts.resolve("edi.mdn.overdue", "edi_outbound_message:" + message.getValue("id"), "MDN received");
}
/*******************************************************************************
** Keep an error within its column.
*******************************************************************************/
private static String truncate(String text)
{
return (text.length() <= 255 ? text : text.substring(0, 255));
}
}
@@ -1,158 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.time.Duration;
import java.time.Instant;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.flow.OutboundWorker;
import com.kingsrook.qbits.edi.flow.SendWorker;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Delivers the asynchronous MDNs we owe partners. The edi_as2_mdn row is the
** queue entry (deliveryStatus PENDING): a scheduled step posts the stored MDN to
** the address the partner gave in Receipt-Delivery-Option, retries with the send
** backoff, and fails (with an alert) after the transport maxAttempts. Row locks
** fence concurrent senders.
*******************************************************************************/
public class As2MdnSender
{
private static final QLogger LOG = QLogger.getLogger(As2MdnSender.class);
/*******************************************************************************
** Deliver pending MDNs that are due; returns how many were attempted.
*******************************************************************************/
public Integer runOnce() throws QException
{
return (SystemWrites.call(() ->
{
int count = 0;
var due = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, EdiRuntime.current().config().getClock().instant());
for(QRecord row : EdiRecords.query(As2Store.TABLE, new QQueryFilter().withCriteria("deliveryStatus", QCriteriaOperator.EQUALS, "PENDING").withSubFilter(due).withLimit(20)))
{
count += deliver(row);
}
return (count);
}));
}
/*******************************************************************************
** One delivery attempt, fenced by a row lock on the selected state.
*******************************************************************************/
private Integer deliver(QRecord selected) throws QException
{
return (SystemWrites.transaction(() ->
{
if(!SystemWrites.guard("UPDATE edi_as2_mdn SET id=id WHERE id=? AND delivery_status='PENDING' AND COALESCE(delivery_attempts,0)=?", selected.getValue("id"),
Objects.requireNonNullElse(selected.getValueInteger("deliveryAttempts"), 0)))
{
return (0);
}
QRecord row = OutboundWorker.row(As2Store.TABLE, selected.getValue("id"));
EdiTransport transport = OutboundWorker.row("edi_transport", row.getValue("transportId")).toEntity(EdiTransport.class);
int attempts = Objects.requireNonNullElse(row.getValueInteger("deliveryAttempts"), 0) + 1;
boolean delivered = post(row, transport);
int maximum = Objects.requireNonNullElse(transport.getMaxAttempts(), 8);
Instant now = EdiRuntime.current().config().getClock().instant();
String status = delivered ? "SENT" : attempts >= maximum ? "FAILED" : "PENDING";
QRecord update = new QRecord().withValue("id", row.getValue("id")).withValue("deliveryStatus", status).withValue("deliveryAttempts", attempts)
.withValue("nextAttemptAt", "PENDING".equals(status) ? now.plusSeconds(SendWorker.BACKOFF.get(Math.min(attempts - 1, SendWorker.BACKOFF.size() - 1))) : null);
if(delivered)
{
update.withValue("deliveredAt", now);
}
SystemWrites.update(As2Store.TABLE, update);
if("FAILED".equals(status))
{
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_as2_mdn:" + row.getValue("id"))
.withTitle("AS2 receipt delivery failed").withSummary("Review the AS2 receipt"));
}
return (1);
}));
}
/*******************************************************************************
** POST the stored MDN to the partner receipt address; true on any 2xx.
*******************************************************************************/
private boolean post(QRecord row, EdiTransport transport)
{
try
{
byte[] raw;
try(InputStream stream = EdiRuntime.current().payloads().open(new PayloadRef(row.getValueString("payloadRef"), null, 0)))
{
raw = stream.readAllBytes();
}
As2Crypto.Wire wire = As2Crypto.splitRaw(raw);
Map<String, String> headers = new LinkedHashMap<>();
headers.put("AS2-Version", "1.2");
headers.put("AS2-From", As2Transport.asId(transport.getAs2LocalId()));
headers.put("AS2-To", As2Transport.asId(transport.getAs2PartnerId()));
headers.put("Message-ID", "<" + row.getValueString("messageId") + ">");
headers.put("MIME-Version", "1.0");
headers.put("Content-Type", wire.contentType());
if(wire.transferEncoding() != null)
{
headers.put("Content-Transfer-Encoding", wire.transferEncoding());
}
if(transport.getHttpAuthUserEnvVar() != null)
{
var secrets = EdiRuntime.current().config().getSecretsResolver();
headers.put("Authorization", As2Http.basic(secrets.resolve(transport.getHttpAuthUserEnvVar()), secrets.resolve(transport.getHttpAuthPasswordEnvVar())));
}
HttpClient client = As2Http.client(null);
int status = As2Http.post(client, URI.create(row.getValueString("deliveryUrl")), headers, wire.body(), Duration.ofSeconds(60)).status();
return (status >= 200 && status < 300);
}
catch(Exception e)
{
LOG.warn("AS2 receipt delivery attempt failed for receipt " + row.getValue("id"));
return (false);
}
}
}
@@ -1,356 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.helger.as2lib.disposition.DispositionOptions;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import com.kingsrook.qbits.edi.flow.EdiJournal;
import com.kingsrook.qbits.edi.flow.InboundPayload;
import com.kingsrook.qbits.edi.flow.InboundSink;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import jakarta.mail.internet.MimeBodyPart;
/*******************************************************************************
** Receives AS2 messages for one transport: checks the AS2 identities, decrypts
** with our ACTIVE or NEXT keys, verifies the signature against the partner
** certificates, computes the MIC, hands the document to the inbox, and answers
** with a synchronous MDN or queues an asynchronous one. A retransmitted
** Message-ID gets the original MDN again and is not reprocessed. Callers run
** inside the system context; failures are reported as RFC 4130 dispositions.
*******************************************************************************/
public class As2Receiver
{
private static final QLogger LOG = QLogger.getLogger(As2Receiver.class);
/*******************************************************************************
** An HTTP answer: status, headers and body.
*******************************************************************************/
public record Result(int status, Map<String, String> headers, byte[] body)
{
/*******************************************************************************
** A plain-text answer.
*******************************************************************************/
static Result text(int status, String text)
{
return (new Result(status, Map.of("Content-Type", "text/plain; charset=utf-8"), text.getBytes(StandardCharsets.UTF_8)));
}
}
/*******************************************************************************
** What was asked of us for this message.
*******************************************************************************/
private record Request(String messageId, boolean mdnRequested, String deliveryUrl, boolean signMdn, ECryptoAlgorithmSign micAlgorithm)
{
/*******************************************************************************
** The same request, answered in the HTTP response only. Used before the sender
** is authenticated, so an unauthenticated sender cannot aim a receipt at an
** address of its choosing.
*******************************************************************************/
Request synchronous()
{
return (new Request(messageId, mdnRequested, null, signMdn, micAlgorithm));
}
}
/*******************************************************************************
** Handle one inbound message; header names are lower case.
*******************************************************************************/
public Result receive(EdiTransport transport, Map<String, String> headers, byte[] body, String remoteAddress) throws Exception
{
String messageId = As2Mdn.normalizeId(headers.get("message-id"));
String from = As2Mdn.normalizeId(headers.get("as2-from"));
String to = As2Mdn.normalizeId(headers.get("as2-to"));
if(messageId == null || messageId.isBlank() || messageId.length() > 250 || !messageId.matches("[\\x21-\\x7E]+") || from == null || to == null)
{
return (Result.text(400, "The AS2-From, AS2-To and Message-ID headers are required"));
}
if(!from.equalsIgnoreCase(transport.getAs2PartnerId()) || !to.equalsIgnoreCase(transport.getAs2LocalId()))
{
LOG.warn("AS2 message refused for transport " + transport.getName() + ": unknown AS2 identity");
return (Result.text(403, "Unknown AS2 identity"));
}
Request request = request(transport, headers, messageId);
if(request.deliveryUrl() != null && !deliveryUrlAllowed(transport, request.deliveryUrl()))
{
return (Result.text(400, "The Receipt-Delivery-Option is not acceptable"));
}
CertificateService certificates = new CertificateService();
CertificateService.KeySet keys = certificates.localDecryptionKeys();
As2Crypto.Decoded decoded;
try
{
List<X509Certificate> partnerCertificates = transport.getPartnerCertificateId() == null ? List.of() : certificates.partnerVerificationCertificates(transport);
byte[] top = As2Crypto.entity(headers.get("content-type"), headers.get("content-transfer-encoding"), headers.get("content-disposition"), body);
decoded = As2Crypto.decode(top, keys.keys(), partnerCertificates, request.micAlgorithm(), Date.from(EdiRuntime.current().config().getClock().instant()));
if((transport.getSignAlgorithm() != null && !decoded.signed()) || (transport.getEncryptAlgorithm() != null && !decoded.encrypted()))
{
throw (new As2Exception("The message does not meet the transport security requirements", As2Exception.INSUFFICIENT_SECURITY));
}
}
catch(As2Exception e)
{
boolean ourFault = e.getCode() == null || (As2Exception.DECRYPTION_FAILED.equals(e.getCode()) && !keys.problems().isEmpty());
return (reject(transport, request.synchronous(), ourFault ? As2Exception.UNEXPECTED_PROCESSING : e.getCode(), certificates));
}
String externalId = externalId(transport, messageId);
QRecord replay = alreadyAnswered(transport, request, externalId);
if(replay != null)
{
return (replay(transport, request, replay));
}
QRecord inbox;
try
{
inbox = new InboundSink(EdiRuntime.current().payloads()).receive(new InboundPayload("AS2", externalId, decoded.fileName(), remoteAddress, new ByteArrayInputStream(decoded.payload())));
if(inbox.getValue("transportId") == null)
{
SystemWrites.update("edi_inbox", new QRecord().withValue("id", inbox.getValue("id")).withValue("transportId", transport.getId()));
}
}
catch(Exception e)
{
LOG.warn("AS2 intake failed for transport " + transport.getName());
return (reject(transport, request, As2Exception.UNEXPECTED_PROCESSING, certificates));
}
if(!request.mdnRequested())
{
return (new Result(200, Map.of(), new byte[0]));
}
try
{
return (answer(transport, request, As2Mdn.PROCESSED, decoded.mic(), inbox.getValueInteger("id"), certificates));
}
catch(As2Exception e)
{
LOG.warn("AS2 receipt could not be produced for transport " + transport.getName() + ": " + e.getMessage());
return (Result.text(500, "The receipt could not be produced"));
}
}
/*******************************************************************************
** Read what the message asks for: whether a receipt is wanted, where an
** asynchronous one goes, and whether and how to sign it.
*******************************************************************************/
private Request request(EdiTransport transport, Map<String, String> headers, String messageId)
{
DispositionOptions options = new DispositionOptions();
try
{
options = DispositionOptions.createFromString(headers.get("disposition-notification-options"));
}
catch(Exception e)
{
LOG.warn("AS2 disposition options ignored for transport " + transport.getName());
}
ECryptoAlgorithmSign algorithm = options.getFirstMICAlg();
if(algorithm == null)
{
algorithm = As2Crypto.signAlgorithm(transport.getSignAlgorithm()) == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : As2Crypto.signAlgorithm(transport.getSignAlgorithm());
}
String delivery = headers.get("receipt-delivery-option");
return (new Request(messageId, headers.get("disposition-notification-to") != null, delivery == null || delivery.isBlank() ? null : delivery.trim(),
options.getProtocol() != null || Boolean.TRUE.equals(transport.getMdnSigned()), algorithm));
}
/*******************************************************************************
** An asynchronous receipt address must use TLS (loopback excepted) and, unless
** the transport requires signed messages, must be on the partner's own host,
** so an unauthenticated sender cannot aim our receipts at third parties.
*******************************************************************************/
static boolean deliveryUrlAllowed(EdiTransport transport, String url)
{
if(!As2Http.isAllowedUrl(url))
{
return (false);
}
if(transport.getSignAlgorithm() != null)
{
return (true);
}
try
{
return (transport.getAs2Url() != null && java.net.URI.create(url).getHost().equalsIgnoreCase(java.net.URI.create(transport.getAs2Url()).getHost()));
}
catch(Exception e)
{
return (false);
}
}
/*******************************************************************************
** The inbox dedupe key: the Message-ID. Message-IDs are unique per sender, so if
** the same Message-ID was already received on a different transport (another
** partner), the key is scoped by the transport id so neither message is lost.
*******************************************************************************/
private static String externalId(EdiTransport transport, String messageId) throws Exception
{
List<QRecord> rows = EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2").withCriteria("externalId", QCriteriaOperator.EQUALS, messageId));
boolean foreign = !rows.isEmpty() && rows.getFirst().getValue("transportId") != null && !rows.getFirst().getValue("transportId").equals(transport.getId());
return (foreign ? transport.getId() + ":" + messageId : messageId);
}
/*******************************************************************************
** The receipt row we already sent for this Message-ID (a retransmission), or
** null when the original was never answered.
*******************************************************************************/
private QRecord alreadyAnswered(EdiTransport transport, Request request, String externalId) throws Exception
{
if(EdiRecords.query("edi_inbox", EdiRecords.equal("transportType", "AS2").withCriteria("externalId", QCriteriaOperator.EQUALS, externalId)).isEmpty())
{
return (null);
}
List<QRecord> rows = EdiRecords.query(As2Store.TABLE, EdiRecords.equal("direction", "OUTBOUND").withCriteria("originalMessageId", QCriteriaOperator.EQUALS, request.messageId())
.withCriteria("transportId", QCriteriaOperator.EQUALS, transport.getId()));
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** Answer a retransmission with the original receipt, unchanged. An
** asynchronous receipt is queued to be delivered again.
*******************************************************************************/
private Result replay(EdiTransport transport, Request request, QRecord original) throws Exception
{
if(!request.mdnRequested())
{
return (new Result(200, Map.of(), new byte[0]));
}
if(request.deliveryUrl() != null)
{
SystemWrites.update(As2Store.TABLE, new QRecord().withValue("id", original.getValue("id")).withValue("deliveryUrl", request.deliveryUrl()).withValue("deliveryStatus", "PENDING")
.withValue("deliveryAttempts", 0).withValue("nextAttemptAt", null));
return (new Result(200, Map.of(), new byte[0]));
}
byte[] raw;
try(InputStream stream = EdiRuntime.current().payloads().open(new PayloadRef(original.getValueString("payloadRef"), null, 0)))
{
raw = stream.readAllBytes();
}
return (syncResult(transport, original.getValueString("messageId"), As2Crypto.splitRaw(raw)));
}
/*******************************************************************************
** Reject a message that could not be accepted. With a receipt requested the
** failure is reported in an MDN so the partner can act or retry; otherwise an
** HTTP error answers. No inbox row exists for a rejected message.
*******************************************************************************/
private Result reject(EdiTransport transport, Request request, String code, CertificateService certificates) throws Exception
{
LOG.warn("AS2 message rejected for transport " + transport.getName() + ": " + code);
if(!request.mdnRequested())
{
return (Result.text(As2Exception.UNEXPECTED_PROCESSING.equals(code) ? 500 : 400, "AS2 message rejected: " + code));
}
try
{
return (answer(transport, request, As2Mdn.failure(code), null, null, certificates));
}
catch(As2Exception e)
{
LOG.warn("AS2 receipt could not be produced for transport " + transport.getName() + ": " + e.getMessage());
return (Result.text(500, "The receipt could not be produced"));
}
}
/*******************************************************************************
** Build, store and answer or queue the MDN.
*******************************************************************************/
private Result answer(EdiTransport transport, Request request, String disposition, MIC mic, Integer inboxId, CertificateService certificates) throws Exception
{
LocalKey key = request.signMdn() ? certificates.activeLocal(transport) : null;
String mdnId = "edi-mdn-" + UUID.randomUUID() + "@" + transport.getAs2LocalId().replaceAll("[^A-Za-z0-9.-]", "-");
As2Mdn.Built built = As2Mdn.build(mdnId, transport.getAs2LocalId(), request.messageId(), disposition, mic, key, request.micAlgorithm());
PayloadRef raw = As2Store.storeRaw(built.entity());
Instant now = EdiRuntime.current().config().getClock().instant();
QRecord row = As2Store.insert(new QRecord().withValue("direction", "OUTBOUND").withValue("messageId", mdnId).withValue("originalMessageId", request.messageId())
.withValue("disposition", disposition).withValue("micAlgorithm", mic == null ? null : mic.getDigestAlgorithm().getID()).withValue("micValue", As2Store.micValue(mic))
.withValue("signed", built.signed()).withValue("payloadRef", raw.value()).withValue("receivedAt", now).withValue("transportId", transport.getId())
.withValue("partnerId", transport.getPartnerId()).withValue("inboxId", inboxId).withValue("deliveryStatus", request.deliveryUrl() == null ? "NONE" : "PENDING")
.withValue("deliveryUrl", request.deliveryUrl()).withValue("deliveryAttempts", 0).withValue("nextAttemptAt", null));
if(inboxId != null)
{
EdiJournal.event(inboxId, null, null, "MDN");
}
LOG.info("AS2 receipt " + row.getValue("id") + " " + (request.deliveryUrl() == null ? "returned" : "queued") + " for transport " + transport.getName());
return (request.deliveryUrl() != null ? new Result(200, Map.of(), new byte[0]) : syncResult(transport, mdnId, built.wire()));
}
/*******************************************************************************
** The synchronous MDN response with its AS2 headers.
*******************************************************************************/
private Result syncResult(EdiTransport transport, String mdnId, As2Crypto.Wire wire)
{
Map<String, String> headers = new LinkedHashMap<>();
headers.put("AS2-Version", "1.2");
headers.put("AS2-From", As2Transport.asId(transport.getAs2LocalId()));
headers.put("AS2-To", As2Transport.asId(transport.getAs2PartnerId()));
headers.put("Message-ID", "<" + mdnId + ">");
headers.put("MIME-Version", "1.0");
headers.put("Content-Type", wire.contentType());
if(wire.transferEncoding() != null)
{
headers.put("Content-Transfer-Encoding", wire.transferEncoding());
}
return (new Result(200, headers, wire.body()));
}
}
@@ -1,148 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.TreeMap;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.middleware.javalin.QJavalinRouteProviderInterface;
import io.javalin.config.JavalinConfig;
import io.javalin.http.Context;
/*******************************************************************************
** AS2 machine endpoints outside the UI API, authenticated per transport by AS2
** identity, S/MIME signature and certificates (never a user session):
** POST /qqq/hooks/edi/as2/{transport} receives messages and answers with the
** MDN, and POST /qqq/hooks/edi/as2/{transport}/mdn receives asynchronous MDNs.
*******************************************************************************/
public class As2RouteProvider implements QJavalinRouteProviderInterface
{
private static final QLogger LOG = QLogger.getLogger(As2RouteProvider.class);
public static final long DEFAULT_MAX_BODY_BYTES = 50L * 1024 * 1024;
public static final long ABSOLUTE_MAX_BODY_BYTES = 200L * 1024 * 1024;
private QInstance instance;
/*******************************************************************************
** Host instance supplied by Javalin.
*******************************************************************************/
public void setQInstance(QInstance instance)
{
this.instance = instance;
}
/*******************************************************************************
** Register the non-UI hooks.
*******************************************************************************/
public void acceptJavalinConfig(JavalinConfig config)
{
config.routes.post("/qqq/hooks/edi/as2/{transport}", context -> handle(context, false));
config.routes.post("/qqq/hooks/edi/as2/{transport}/mdn", context -> handle(context, true));
}
/*******************************************************************************
** Find the active AS2 transport, read the bounded body and delegate.
*******************************************************************************/
private void handle(Context context, boolean mdn)
{
var previous = QContext.capture();
try
{
QContext.init(instance, null);
As2Receiver.Result result = SystemWrites.call(() -> process(context, mdn));
context.status(result.status());
result.headers().forEach(context::header);
context.result(result.body());
}
catch(Exception e)
{
LOG.warn("AS2 hook failed: " + e.getClass().getSimpleName());
context.status(503);
context.result("The AS2 request could not be processed".getBytes(java.nio.charset.StandardCharsets.UTF_8));
}
finally
{
QContext.init(previous);
}
}
/*******************************************************************************
** The hook body, inside the system context.
*******************************************************************************/
private As2Receiver.Result process(Context context, boolean mdn) throws Exception
{
List<QRecord> rows = EdiRecords.query("edi_transport", EdiRecords.equal("name", context.pathParam("transport")).withCriteria("type", QCriteriaOperator.EQUALS, "AS2")
.withCriteria("isActive", QCriteriaOperator.EQUALS, true));
if(rows.size() != 1 || (mdn ? "INBOUND" : "OUTBOUND").equals(rows.getFirst().getValueString("direction")))
{
return (As2Receiver.Result.text(404, "Unknown AS2 transport"));
}
EdiTransport transport = rows.getFirst().toEntity(EdiTransport.class);
long limit = mdn ? As2Http.MAX_RESPONSE_BYTES : maxBodyBytes(transport);
if(context.req().getContentLengthLong() > limit)
{
return (As2Receiver.Result.text(413, "The message is too large"));
}
byte[] body = context.req().getInputStream().readNBytes((int) limit + 1);
if(body.length > limit)
{
return (As2Receiver.Result.text(413, "The message is too large"));
}
Map<String, String> headers = new TreeMap<>();
context.headerMap().forEach((name, value) -> headers.put(name.toLowerCase(Locale.ROOT), value));
return (mdn ? new As2MdnReceiver().receive(transport, headers, body) : new As2Receiver().receive(transport, headers, body, context.ip()));
}
/*******************************************************************************
** The largest interchange any agreement on this transport allows, or 50 MB.
*******************************************************************************/
private static long maxBodyBytes(EdiTransport transport) throws Exception
{
long limit = 0;
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("inboundTransportId", transport.getId())))
{
limit = Math.max(limit, agreement.getValueLong("maxInterchangeBytes") == null ? 0 : agreement.getValueLong("maxInterchangeBytes"));
}
return (limit <= 0 ? DEFAULT_MAX_BODY_BYTES : Math.min(limit + limit / 2 + 65536, ABSOLUTE_MAX_BODY_BYTES));
}
}
@@ -1,86 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.time.Instant;
import com.helger.as2lib.crypto.MIC;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.PayloadKind;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Persistence helpers for AS2 receipts: the raw MDN goes to the encrypted payload
** store and a read-only edi_as2_mdn row describes it. Callers run inside the
** system context.
*******************************************************************************/
public final class As2Store
{
public static final String TABLE = "edi_as2_mdn";
/*******************************************************************************
** Utility class.
*******************************************************************************/
private As2Store()
{
}
/*******************************************************************************
** Store the raw MDN entity (headers and body) encrypted.
*******************************************************************************/
public static PayloadRef storeRaw(byte[] entity) throws Exception
{
return (EdiRuntime.current().payloads().put(PayloadKind.MDN, new ByteArrayInputStream(entity)));
}
/*******************************************************************************
** The base64 value of a MIC without its algorithm.
*******************************************************************************/
public static String micValue(MIC mic)
{
return (mic == null ? null : java.util.Base64.getEncoder().encodeToString(mic.micBytes()));
}
/*******************************************************************************
** Insert a receipt row and return it.
*******************************************************************************/
public static QRecord insert(QRecord row) throws QException
{
if(row.getValue("receivedAt") == null)
{
row.setValue("receivedAt", Instant.now(EdiRuntime.current().config().getClock()));
}
return (SystemWrites.insert(TABLE, row));
}
}
@@ -1,584 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.InputStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.security.MessageDigest;
import java.security.cert.X509Certificate;
import java.time.Duration;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Date;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.helger.as2lib.crypto.ECryptoAlgorithmCrypt;
import com.helger.as2lib.crypto.ECryptoAlgorithmSign;
import com.helger.as2lib.crypto.MIC;
import com.helger.as2lib.disposition.DispositionOptions;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.as2.CertificateService.LocalKey;
import com.kingsrook.qbits.edi.flow.EdiJournal;
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qbits.edi.transport.EdiSendResult;
import com.kingsrook.qbits.edi.transport.EdiTestResult;
import com.kingsrook.qbits.edi.transport.EdiTransportPlugin;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** AS2 (RFC 4130) transport. Sending builds the message (compress, sign, then
** encrypt), POSTs it, and for a synchronous receipt verifies the MDN signature,
** the received-content MIC and the disposition. An asynchronous receipt leaves
** the message SENT until the MDN arrives at the receipt hook. No local files are
** used; keys come from environment variables through the secrets resolver.
*******************************************************************************/
public class As2Transport implements EdiTransportPlugin
{
public static final String TYPE = "AS2";
private static final String ID_PATTERN = "[A-Za-z0-9 ._@:/-]{1,128}";
private static final Duration TIMEOUT = Duration.ofSeconds(60);
/*******************************************************************************
** The prepared wire message and the MIC computed for it.
*******************************************************************************/
private record Prepared(As2Crypto.Wire wire, MIC mic)
{
}
/*******************************************************************************
** Registry key.
*******************************************************************************/
@Override
public String getType()
{
return (TYPE);
}
/*******************************************************************************
** Check identifiers, URL, algorithms, receipt mode and certificate ownership
** without resolving any secret. SHA1 and 3DES are accepted only when set
** explicitly; they set the weakCrypto flag on the record.
*******************************************************************************/
@Override
public void validate(EdiTransport transport, List<String> errors)
{
boolean outbound = !"INBOUND".equals(transport.getDirection());
if(transport.getAs2LocalId() == null || !transport.getAs2LocalId().matches(ID_PATTERN))
{
errors.add("as2LocalId is required (letters, digits, spaces and . _ @ : / - only)");
}
if(transport.getAs2PartnerId() == null || !transport.getAs2PartnerId().matches(ID_PATTERN))
{
errors.add("as2PartnerId is required (letters, digits, spaces and . _ @ : / - only)");
}
if(outbound && (transport.getAs2Url() == null || !As2Http.isAllowedUrl(transport.getAs2Url())))
{
errors.add("as2Url must use TLS (HTTP is allowed on loopback only) and carry no credentials");
}
try
{
As2Crypto.signAlgorithm(transport.getSignAlgorithm());
}
catch(IllegalArgumentException e)
{
errors.add("Unsupported signAlgorithm");
}
try
{
As2Crypto.encryptAlgorithm(transport.getEncryptAlgorithm());
}
catch(IllegalArgumentException e)
{
errors.add("Unsupported encryptAlgorithm");
}
if(transport.getMdnMode() != null && !List.of("SYNC", "ASYNC", "NONE").contains(transport.getMdnMode()))
{
errors.add("Unsupported mdnMode");
}
if(Boolean.TRUE.equals(transport.getCompress()) && transport.getSignAlgorithm() == null && transport.getEncryptAlgorithm() == null)
{
errors.add("compress requires signing or encryption");
}
if(transport.getPartnerId() == null && !Boolean.TRUE.equals(transport.getIsVan()))
{
errors.add("partnerId is required unless the transport is a VAN (isVan)");
}
if((transport.getHttpAuthUserEnvVar() == null) != (transport.getHttpAuthPasswordEnvVar() == null))
{
errors.add("httpAuthUserEnvVar and httpAuthPasswordEnvVar must be set together");
}
if(outbound && "ASYNC".equals(transport.getMdnMode()) && baseUrl() == null)
{
errors.add("ASYNC receipts need the qbit publicBaseUrl configuration");
}
try
{
validateCertificates(transport, errors);
}
catch(Exception e)
{
errors.add("Certificates could not be checked");
}
}
/*******************************************************************************
** Certificate references must exist and have the right owner: ours (no
** partner, with a key variable) as the local certificate; the partner's own, or
** for a VAN a certificate that is not ours, as the partner certificate; a TLS
** certificate for pinning. Retired certificates are refused.
*******************************************************************************/
private static void validateCertificates(EdiTransport transport, List<String> errors) throws Exception
{
boolean secured = transport.getSignAlgorithm() != null || transport.getEncryptAlgorithm() != null || Boolean.TRUE.equals(transport.getMdnSigned());
QRecord local = certificate(transport.getLocalCertificateId());
QRecord partner = certificate(transport.getPartnerCertificateId());
QRecord tls = certificate(transport.getPartnerTlsCertificateId());
if(transport.getLocalCertificateId() == null ? secured : local == null)
{
errors.add(transport.getLocalCertificateId() == null ? "localCertificateId is required when signing or encrypting" : "localCertificateId does not exist");
}
else if(local != null && (local.getValue("partnerId") != null || local.getValueString("privateKeyEnvVar") == null || "RETIRED".equals(local.getValueString("status"))))
{
errors.add("localCertificateId must be one of our own certificates (no partner, with a private key variable) that is not retired");
}
if(transport.getPartnerCertificateId() == null ? secured : partner == null)
{
errors.add(transport.getPartnerCertificateId() == null ? "partnerCertificateId is required when signing or encrypting" : "partnerCertificateId does not exist");
}
else if(partner != null)
{
boolean ownedByPartner = transport.getPartnerId() == null || transport.getPartnerId().equals(partner.getValueInteger("partnerId"));
if(!ownedByPartner || partner.getValueString("privateKeyEnvVar") != null || "RETIRED".equals(partner.getValueString("status")))
{
errors.add("partnerCertificateId must be a certificate of the partner (or, for a VAN, of the VAN) that is not retired and holds no private key");
}
}
if(transport.getPartnerTlsCertificateId() != null && (tls == null || !"TLS".equals(tls.getValueString("usage"))))
{
errors.add("partnerTlsCertificateId must be a certificate with usage TLS");
}
}
/*******************************************************************************
** Certificate row by id, or null.
*******************************************************************************/
private static QRecord certificate(Integer id) throws Exception
{
if(id == null)
{
return (null);
}
return (SystemWrites.call(() -> EdiRecords.query(CertificateService.TABLE, EdiRecords.equal("id", id)).stream().findFirst().orElse(null)));
}
/*******************************************************************************
** The deployment public base URL without a trailing slash, or null.
*******************************************************************************/
static String baseUrl()
{
try
{
String url = EdiRuntime.current().config().getPublicBaseUrl();
return (url == null || url.isBlank() ? null : url.replaceAll("/+$", ""));
}
catch(Exception e)
{
return (null);
}
}
/*******************************************************************************
** Build, sign, encrypt and POST the message; settle it from the receipt.
*******************************************************************************/
@Override
public EdiSendResult send(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception
{
return (SystemWrites.call(() -> deliver(message, payload, transport)));
}
/*******************************************************************************
** The send itself, inside the system context. Configuration problems (a
** missing variable, an expired or missing certificate) are RETRY results that
** name only the problem, so the retry policy and alerts apply once fixed.
*******************************************************************************/
private EdiSendResult deliver(EdiOutboundMessage message, PayloadRef payload, EdiTransport transport) throws Exception
{
byte[] edi;
try(InputStream stream = EdiRuntime.current().payloads().open(payload))
{
edi = stream.readAllBytes();
}
String messageId = messageId(message.getId(), transport, edi);
CertificateService certificates = new CertificateService();
String mode = mode(transport);
As2Http.Response response;
Prepared prepared;
try
{
prepared = prepare(message, transport, edi, messageId, certificates);
HttpClient client = As2Http.client(certificates.tlsPin(transport));
response = As2Http.post(client, URI.create(transport.getAs2Url()), headers(transport, messageId, prepared, mode), prepared.wire().body(), TIMEOUT);
}
catch(As2Exception e)
{
return (new EdiSendResult("RETRY", null, e.getMessage()));
}
catch(java.io.IOException e)
{
return (new EdiSendResult("RETRY", null, "The AS2 endpoint could not be reached"));
}
if(response.status() >= 500 || response.status() == 429)
{
return (new EdiSendResult("RETRY", null, "HTTP " + response.status()));
}
if(response.status() < 200 || response.status() >= 300)
{
return (new EdiSendResult("FAILED", null, "HTTP " + response.status()));
}
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getId()).withValue("transportMic", prepared.mic().getAsAS2String()));
if(!"SYNC".equals(mode))
{
return (new EdiSendResult("SENT", messageId, "ASYNC".equals(mode) ? "Awaiting the asynchronous MDN" : "No receipt requested"));
}
return (settleSync(message, transport, messageId, prepared, response, certificates));
}
/*******************************************************************************
** Compress, sign and encrypt as configured, computing the MIC over the content
** that is signed.
*******************************************************************************/
private Prepared prepare(EdiOutboundMessage message, EdiTransport transport, byte[] edi, String messageId, CertificateService certificates) throws Exception
{
ECryptoAlgorithmSign signAlgorithm = As2Crypto.signAlgorithm(transport.getSignAlgorithm());
ECryptoAlgorithmCrypt encryptAlgorithm = As2Crypto.encryptAlgorithm(transport.getEncryptAlgorithm());
LocalKey key = signAlgorithm == null ? null : certificates.activeLocal(transport);
X509Certificate partner = encryptAlgorithm == null ? null : certificates.activePartnerCertificate(transport);
checkValid("Local certificate", key == null ? null : key.certificate());
checkValid("Partner certificate", partner);
String contentType = edi.length >= 3 && edi[0] == 'U' && edi[1] == 'N' ? "application/edifact" : "application/edi-x12";
var part = As2Crypto.payloadPart(edi, contentType, "edi-" + message.getId() + ".edi");
if(Boolean.TRUE.equals(transport.getCompress()))
{
part = As2Crypto.compress(part);
}
ECryptoAlgorithmSign micAlgorithm = signAlgorithm == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : signAlgorithm;
byte[] clear;
MIC mic;
if(signAlgorithm == null)
{
clear = As2Crypto.toBytes(part);
mic = As2Crypto.mic(clear, micAlgorithm);
}
else
{
clear = As2Crypto.toBytes(As2Crypto.sign(part, key, signAlgorithm));
As2Crypto.Wire signed = As2Crypto.splitRaw(clear);
mic = As2Crypto.mic(As2Crypto.firstPart(signed.contentType(), signed.body()), micAlgorithm);
}
return (new Prepared(encryptAlgorithm == null ? As2Crypto.splitRaw(clear) : As2Crypto.encrypt(clear, partner, encryptAlgorithm), mic));
}
/*******************************************************************************
** Certificates outside their validity period are not used.
*******************************************************************************/
private static void checkValid(String what, X509Certificate certificate) throws As2Exception
{
if(certificate != null)
{
try
{
certificate.checkValidity();
}
catch(Exception e)
{
throw (new As2Exception(what + " " + certificate.getSerialNumber().toString(16) + " is expired or not yet valid"));
}
}
}
/*******************************************************************************
** The AS2 request headers.
*******************************************************************************/
private Map<String, String> headers(EdiTransport transport, String messageId, Prepared prepared, String mode) throws Exception
{
Map<String, String> headers = new LinkedHashMap<>();
headers.put("AS2-Version", "1.2");
headers.put("AS2-From", asId(transport.getAs2LocalId()));
headers.put("AS2-To", asId(transport.getAs2PartnerId()));
headers.put("Message-ID", "<" + messageId + ">");
headers.put("Subject", subject(transport));
headers.put("Date", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(EdiRuntime.current().config().getClock().withZone(ZoneOffset.UTC))));
headers.put("MIME-Version", "1.0");
headers.put("User-Agent", "qbit-edi");
headers.put("Content-Type", prepared.wire().contentType());
if(prepared.wire().transferEncoding() != null)
{
headers.put("Content-Transfer-Encoding", prepared.wire().transferEncoding());
}
if(prepared.wire().contentDisposition() != null)
{
headers.put("Content-Disposition", prepared.wire().contentDisposition());
}
if(!"NONE".equals(mode))
{
headers.put("Disposition-Notification-To", transport.getAs2LocalId());
if(Boolean.TRUE.equals(transport.getMdnSigned()))
{
ECryptoAlgorithmSign alg = As2Crypto.signAlgorithm(transport.getSignAlgorithm()) == null ? ECryptoAlgorithmSign.DIGEST_SHA_256 : As2Crypto.signAlgorithm(transport.getSignAlgorithm());
headers.put("Disposition-Notification-Options", new DispositionOptions().setProtocolImportance(DispositionOptions.IMPORTANCE_REQUIRED)
.setProtocol(DispositionOptions.PROTOCOL_PKCS7_SIGNATURE).setMICAlgImportance(DispositionOptions.IMPORTANCE_REQUIRED).setMICAlg(alg).getAsString());
}
}
if("ASYNC".equals(mode))
{
headers.put("Receipt-Delivery-Option", baseUrl() + "/qqq/hooks/edi/as2/" + transport.getName() + "/mdn");
}
if(transport.getHttpAuthUserEnvVar() != null)
{
headers.put("Authorization", As2Http.basic(secret(transport.getHttpAuthUserEnvVar()), secret(transport.getHttpAuthPasswordEnvVar())));
}
return (headers);
}
/*******************************************************************************
** Evaluate a synchronous receipt: signature, disposition and MIC.
*******************************************************************************/
private EdiSendResult settleSync(EdiOutboundMessage message, EdiTransport transport, String messageId, Prepared prepared, As2Http.Response response, CertificateService certificates) throws Exception
{
String contentType = response.headers().get("content-type");
if(response.body().length == 0 || contentType == null)
{
return (new EdiSendResult("RETRY", messageId, "The partner returned no MDN"));
}
As2Mdn.Parsed mdn;
try
{
mdn = As2Mdn.parse(contentType, response.headers().get("content-transfer-encoding"), response.body(), certificates.partnerVerificationCertificates(transport),
Date.from(EdiRuntime.current().config().getClock().instant()));
}
catch(As2Exception e)
{
return (new EdiSendResult("RETRY", messageId, e.getMessage()));
}
String failure = judge(transport, mdn, prepared.mic());
PayloadRef raw = As2Store.storeRaw(As2Crypto.entity(contentType, response.headers().get("content-transfer-encoding"), null, response.body()));
As2Store.insert(new QRecord().withValue("direction", "INBOUND").withValue("messageId", As2Mdn.sanitize(As2Mdn.normalizeId(response.headers().get("message-id"))))
.withValue("originalMessageId", messageId).withValue("disposition", mdn.disposition()).withValue("micAlgorithm", mdn.mic() == null ? null : mdn.mic().getDigestAlgorithm().getID())
.withValue("micValue", As2Store.micValue(mdn.mic())).withValue("signed", mdn.signed()).withValue("signatureValid", mdn.signatureValid()).withValue("payloadRef", raw.value())
.withValue("transportId", transport.getId()).withValue("partnerId", message.getPartnerId()).withValue("outboundMessageId", message.getId()).withValue("deliveryStatus", "NONE"));
EdiJournal.event(null, message.getInterchangeId(), null, "MDN");
if(failure != null)
{
return (new EdiSendResult("FAILED", messageId, failure));
}
QAlerts.resolve("edi.mdn.overdue", "edi_outbound_message:" + message.getId(), "MDN received");
return (new EdiSendResult("DELIVERED", messageId, "MDN processed"));
}
/*******************************************************************************
** The reason a receipt does not confirm delivery, or null when it does.
*******************************************************************************/
static String judge(EdiTransport transport, As2Mdn.Parsed mdn, MIC ours)
{
if(Boolean.TRUE.equals(mdn.signed()) && !Boolean.TRUE.equals(mdn.signatureValid()))
{
return ("The MDN signature could not be verified");
}
if(Boolean.TRUE.equals(transport.getMdnSigned()) && !Boolean.TRUE.equals(mdn.signed()))
{
return ("The MDN is not signed");
}
if(!mdn.processed())
{
return ("Partner disposition: " + mdn.disposition());
}
if(ours != null && (mdn.mic() == null ? Boolean.TRUE.equals(transport.getMdnSigned()) || mdn.micText() != null : !mdn.mic().equals(ours)))
{
return ("The MDN Received-Content-MIC does not match the message sent");
}
return (null);
}
/*******************************************************************************
** Probe configuration without sending any document: resolve keys and
** certificates, then OPTIONS the endpoint.
*******************************************************************************/
@Override
public EdiTestResult test(EdiTransport transport)
{
try
{
return (SystemWrites.call(() -> probe(transport)));
}
catch(Exception e)
{
return (new EdiTestResult(false, "AS2 test could not run; check the database and configuration"));
}
}
/*******************************************************************************
** Resolve keys and certificates and reach the endpoint.
*******************************************************************************/
private EdiTestResult probe(EdiTransport transport) throws Exception
{
CertificateService certificates = new CertificateService();
List<String> notes = new java.util.ArrayList<>();
try
{
boolean secured = transport.getSignAlgorithm() != null || transport.getEncryptAlgorithm() != null || Boolean.TRUE.equals(transport.getMdnSigned());
if(secured || transport.getLocalCertificateId() != null)
{
LocalKey key = certificates.activeLocal(transport);
notes.add("Local key " + key.row().getValueString("alias") + " resolved (valid until " + key.certificate().getNotAfter().toInstant() + ")");
}
if(secured || transport.getPartnerCertificateId() != null)
{
certificates.activePartnerCertificate(transport);
notes.add(certificates.partnerVerificationCertificates(transport).size() + " partner certificate(s) accepted for verification");
}
HttpClient client = As2Http.client(certificates.tlsPin(transport));
if(!"INBOUND".equals(transport.getDirection()) && transport.getAs2Url() != null)
{
int status = As2Http.options(client, URI.create(transport.getAs2Url()), Map.of(), Duration.ofSeconds(15)).status();
notes.add("Endpoint responded with HTTP " + status);
if(status >= 500)
{
return (new EdiTestResult(false, String.join("; ", notes)));
}
}
}
catch(As2Exception e)
{
notes.add(e.getMessage());
return (new EdiTestResult(false, String.join("; ", notes)));
}
catch(java.io.IOException e)
{
notes.add("The AS2 endpoint could not be reached");
return (new EdiTestResult(false, String.join("; ", notes)));
}
if(As2Crypto.isWeak(transport.getSignAlgorithm(), transport.getEncryptAlgorithm()))
{
notes.add("Warning: weak crypto (SHA1 or 3DES) is configured");
}
return (new EdiTestResult(true, String.join("; ", notes)));
}
/*******************************************************************************
** Receipt mode, defaulting to SYNC.
*******************************************************************************/
static String mode(EdiTransport transport)
{
return (transport.getMdnMode() == null ? "SYNC" : transport.getMdnMode());
}
/*******************************************************************************
** A stable Message-ID: retries of the same outbound message reuse it, so the
** partner can recognize them.
*******************************************************************************/
static String messageId(Integer messageId, EdiTransport transport, byte[] edi) throws Exception
{
String digest = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(edi)).substring(0, 12);
return ("edi-" + messageId + "-" + digest + "@" + transport.getAs2LocalId().replaceAll("[^A-Za-z0-9.-]", "-"));
}
/*******************************************************************************
** An AS2 identifier as a header value; identifiers with spaces are quoted.
*******************************************************************************/
static String asId(String id)
{
return (id.contains(" ") ? "\"" + id + "\"" : id);
}
/*******************************************************************************
** Subject header without line breaks.
*******************************************************************************/
private static String subject(EdiTransport transport)
{
String subject = transport.getAs2Subject() == null || transport.getAs2Subject().isBlank() ? "EDI interchange" : transport.getAs2Subject();
return (subject.replaceAll("[\\r\\n]+", " "));
}
/*******************************************************************************
** Resolve a secret by environment variable name; failure names only the name.
*******************************************************************************/
private static String secret(String variable) throws As2Exception
{
String value = EdiRuntime.current().config().getSecretsResolver().resolve(variable);
if(value == null)
{
throw (new As2Exception("environment variable " + variable + " is not set"));
}
return (value);
}
}
@@ -1,102 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.security.cert.X509Certificate;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Pre-insert and pre-update customizer for edi_certificate. It parses the pasted
** public certificate and fills subject, issuer, serial number, fingerprint and
** validity dates, and refuses a private key pasted into certificatePem (the
** private key belongs in the environment variable named by privateKeyEnvVar).
*******************************************************************************/
public class CertificateCustomizer implements TableCustomizerInterface
{
/*******************************************************************************
** Parse the certificate on save; reject private keys and key variables on
** partner certificates without echoing any value.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords) throws QException
{
for(QRecord record : records)
{
QRecord merged = new QRecord();
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
merged.getValues().putAll(record.getValues());
boolean insert = oldRecords.isEmpty() || record.getValue("id") == null;
if(insert || record.getValues().containsKey("certificatePem"))
{
try
{
X509Certificate certificate = CertificateService.parse(merged.getValueString("certificatePem"));
record.setValue("subject", abbreviate(certificate.getSubjectX500Principal().getName()));
record.setValue("issuer", abbreviate(certificate.getIssuerX500Principal().getName()));
record.setValue("serialNumber", certificate.getSerialNumber().toString(16).toUpperCase());
record.setValue("fingerprintSha256", CertificateService.fingerprint(certificate));
record.setValue("notBefore", certificate.getNotBefore().toInstant());
record.setValue("notAfter", certificate.getNotAfter().toInstant());
merged.setValue("notAfter", certificate.getNotAfter().toInstant());
}
catch(As2Exception e)
{
record.addError(new BadInputStatusMessage(e.getMessage()));
}
}
if(insert && merged.getValueString("status") == null)
{
record.setValue("status", "ACTIVE");
}
if(merged.getValueInteger("partnerId") != null && merged.getValueString("privateKeyEnvVar") != null && !merged.getValueString("privateKeyEnvVar").isBlank())
{
record.addError(new BadInputStatusMessage("privateKeyEnvVar is only for our own certificates; leave it blank on a partner certificate"));
}
Instant notAfter = merged.getValueInstant("notAfter");
if(notAfter != null && merged.getValueInstant("notBefore") != null && !notAfter.isAfter(merged.getValueInstant("notBefore")))
{
record.addError(new BadInputStatusMessage("The certificate validity period is empty"));
}
}
return (records);
}
/*******************************************************************************
** Keep parsed names within the column length.
*******************************************************************************/
private static String abbreviate(String value)
{
return (value.length() <= 255 ? value : value.substring(0, 255));
}
}
@@ -1,474 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.as2;
import java.io.ByteArrayInputStream;
import java.io.StringReader;
import java.nio.charset.StandardCharsets;
import java.security.KeyStore;
import java.security.MessageDigest;
import java.security.PrivateKey;
import java.security.Signature;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.ArrayList;
import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
import java.util.HexFormat;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.logging.QLogger;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import org.bouncycastle.asn1.pkcs.PrivateKeyInfo;
import org.bouncycastle.openssl.PEMKeyPair;
import org.bouncycastle.openssl.PEMParser;
import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter;
import org.bouncycastle.openssl.jcajce.JceOpenSSLPKCS8DecryptorProviderBuilder;
import org.bouncycastle.pkcs.PKCS8EncryptedPrivateKeyInfo;
/*******************************************************************************
** Resolves the certificates and keys an AS2 transport uses. Public certificates
** come from edi_certificate rows; our private keys come only from environment
** variables (a PEM PKCS#8 key or a base64 PKCS#12 store) through the replaceable
** secrets resolver, are matched to the certificate, and are never cached,
** logged, stored or returned. Callers run inside the system context.
*******************************************************************************/
public final class CertificateService
{
private static final QLogger LOG = QLogger.getLogger(CertificateService.class);
public static final String TABLE = "edi_certificate";
/*******************************************************************************
** Our certificate with its matched private key; the key is excluded from any
** string form of this object.
*******************************************************************************/
public record LocalKey(QRecord row, X509Certificate certificate, PrivateKey key)
{
/*******************************************************************************
** Never render key material.
*******************************************************************************/
@Override
public String toString()
{
return ("LocalKey[" + row.getValueString("alias") + "]");
}
}
/*******************************************************************************
** Local keys usable for decryption, plus the problems (naming environment
** variables only) for keys that could not be loaded.
*******************************************************************************/
public record KeySet(List<LocalKey> keys, List<String> problems)
{
}
/*******************************************************************************
** True when the text contains any private key block.
*******************************************************************************/
public static boolean containsPrivateKey(String text)
{
return (text != null && text.toUpperCase().contains("PRIVATE KEY"));
}
/*******************************************************************************
** Parse the first X.509 certificate of a PEM text; private keys are refused
** without echoing any content.
*******************************************************************************/
public static X509Certificate parse(String pem) throws As2Exception
{
if(pem == null || pem.isBlank())
{
throw (new As2Exception("certificatePem is required"));
}
if(containsPrivateKey(pem))
{
throw (new As2Exception("certificatePem must hold only the public certificate; keep the private key in the environment variable named by privateKeyEnvVar"));
}
if(!pem.contains("-----BEGIN CERTIFICATE-----"))
{
throw (new As2Exception("certificatePem must be a PEM encoded X.509 certificate"));
}
Collection<?> certificates;
try
{
certificates = CertificateFactory.getInstance("X.509").generateCertificates(new ByteArrayInputStream(pem.getBytes(StandardCharsets.UTF_8)));
}
catch(Exception e)
{
throw (new As2Exception("certificatePem could not be parsed as an X.509 certificate"));
}
if(certificates.isEmpty() || !(certificates.iterator().next() instanceof X509Certificate certificate))
{
throw (new As2Exception("certificatePem does not contain an X.509 certificate"));
}
return (certificate);
}
/*******************************************************************************
** Upper-case colon separated SHA-256 fingerprint of the certificate.
*******************************************************************************/
public static String fingerprint(X509Certificate certificate) throws As2Exception
{
try
{
return (HexFormat.ofDelimiter(":").withUpperCase().formatHex(MessageDigest.getInstance("SHA-256").digest(certificate.getEncoded())));
}
catch(Exception e)
{
throw (new As2Exception("certificate fingerprint could not be computed"));
}
}
/*******************************************************************************
** Resolve the key used to sign messages and receipts for the transport: the
** referenced certificate when it is ACTIVE, otherwise the ACTIVE certificate
** of ours with the same usage (so a promoted rollover takes effect at once).
*******************************************************************************/
public LocalKey activeLocal(EdiTransport transport) throws As2Exception, QException
{
QRecord referenced = byId(transport.getLocalCertificateId());
if(referenced == null)
{
throw (new As2Exception("The local certificate of transport " + transport.getName() + " is not configured"));
}
QRecord active = "ACTIVE".equals(referenced.getValueString("status")) ? referenced : findActive(null, referenced.getValueString("usage"), true);
if(active == null)
{
throw (new As2Exception("No ACTIVE local certificate is available for transport " + transport.getName()));
}
return (load(active));
}
/*******************************************************************************
** Every local key inbound decryption accepts: our ACTIVE and NEXT certificates
** that can decrypt. A key that cannot be loaded is skipped and reported by
** environment variable name only.
*******************************************************************************/
public KeySet localDecryptionKeys() throws QException
{
List<LocalKey> keys = new ArrayList<>();
List<String> problems = new ArrayList<>();
QQueryFilter filter = new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")
.withCriteria("partnerId", QCriteriaOperator.IS_BLANK).withCriteria("privateKeyEnvVar", QCriteriaOperator.IS_NOT_BLANK);
for(QRecord row : EdiRecords.query(TABLE, filter))
{
if(!List.of("ENCRYPTION", "SIGNING_AND_ENCRYPTION").contains(row.getValueString("usage")))
{
continue;
}
try
{
keys.add(load(row));
}
catch(As2Exception e)
{
problems.add(e.getMessage());
LOG.warn("Local AS2 key skipped: " + e.getMessage());
}
}
return (new KeySet(keys, problems));
}
/*******************************************************************************
** The partner certificate outbound messages are encrypted to: the referenced
** certificate when ACTIVE, otherwise the partner ACTIVE certificate of the
** same usage.
*******************************************************************************/
public X509Certificate activePartnerCertificate(EdiTransport transport) throws As2Exception, QException
{
QRecord referenced = byId(transport.getPartnerCertificateId());
if(referenced == null)
{
throw (new As2Exception("The partner certificate of transport " + transport.getName() + " is not configured"));
}
QRecord active = "ACTIVE".equals(referenced.getValueString("status")) ? referenced : findActive(referenced.getValueInteger("partnerId"), referenced.getValueString("usage"), false);
if(active == null)
{
throw (new As2Exception("No ACTIVE partner certificate is available for transport " + transport.getName()));
}
return (parse(active.getValueString("certificatePem")));
}
/*******************************************************************************
** Partner certificates a signature may verify against: the referenced one and
** the same partner other ACTIVE or NEXT signing certificates. A partner
** certificate with no partner (a shared VAN) has no rollover candidates.
*******************************************************************************/
public List<X509Certificate> partnerVerificationCertificates(EdiTransport transport) throws As2Exception, QException
{
QRecord referenced = byId(transport.getPartnerCertificateId());
if(referenced == null)
{
throw (new As2Exception("The partner certificate of transport " + transport.getName() + " is not configured"));
}
List<QRecord> rows = new ArrayList<>();
if(List.of("ACTIVE", "NEXT").contains(referenced.getValueString("status")))
{
rows.add(referenced);
}
if(referenced.getValueInteger("partnerId") != null)
{
QQueryFilter filter = EdiRecords.equal("partnerId", referenced.getValueInteger("partnerId")).withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")
.withCriteria("usage", QCriteriaOperator.IN, "SIGNING", "SIGNING_AND_ENCRYPTION").withCriteria("privateKeyEnvVar", QCriteriaOperator.IS_BLANK);
for(QRecord row : EdiRecords.query(TABLE, filter))
{
if(rows.stream().noneMatch(existing -> Objects.equals(existing.getValue("id"), row.getValue("id"))))
{
rows.add(row);
}
}
}
List<X509Certificate> certificates = new ArrayList<>();
for(QRecord row : rows)
{
certificates.add(parse(row.getValueString("certificatePem")));
}
return (certificates);
}
/*******************************************************************************
** The pinned TLS certificate of the partner endpoint, or null when no pin is set.
*******************************************************************************/
public X509Certificate tlsPin(EdiTransport transport) throws As2Exception, QException
{
QRecord row = byId(transport.getPartnerTlsCertificateId());
if(transport.getPartnerTlsCertificateId() != null && row == null)
{
throw (new As2Exception("The partner TLS certificate of transport " + transport.getName() + " does not exist"));
}
return (row == null ? null : parse(row.getValueString("certificatePem")));
}
/*******************************************************************************
** Promote a NEXT certificate to ACTIVE and retire the previous ACTIVE one of
** the same partner and usage. Returns false when the certificate is not NEXT.
*******************************************************************************/
public boolean promote(QRecord row) throws QException
{
if(!"NEXT".equals(row.getValueString("status")))
{
return (false);
}
boolean local = row.getValueInteger("partnerId") == null && row.getValueString("privateKeyEnvVar") != null;
QRecord previous = findActive(row.getValueInteger("partnerId"), row.getValueString("usage"), local);
while(previous != null)
{
SystemWrites.update(TABLE, new QRecord().withValue("id", previous.getValue("id")).withValue("status", "RETIRED"));
previous = findActive(row.getValueInteger("partnerId"), row.getValueString("usage"), local);
}
SystemWrites.update(TABLE, new QRecord().withValue("id", row.getValue("id")).withValue("status", "ACTIVE"));
return (true);
}
/*******************************************************************************
** Certificate row by id, or null.
*******************************************************************************/
private QRecord byId(Integer id) throws QException
{
if(id == null)
{
return (null);
}
List<QRecord> rows = EdiRecords.query(TABLE, EdiRecords.equal("id", id));
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** The ACTIVE certificate of a partner (null partner means ours or a shared
** one) and usage, local ones being those with a private key variable.
*******************************************************************************/
private QRecord findActive(Integer partnerId, String usage, boolean local) throws QException
{
QQueryFilter filter = EdiRecords.equal("status", "ACTIVE").withCriteria("partnerId", partnerId == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, partnerId)
.withCriteria("usage", QCriteriaOperator.EQUALS, usage).withCriteria("privateKeyEnvVar", local ? QCriteriaOperator.IS_NOT_BLANK : QCriteriaOperator.IS_BLANK);
List<QRecord> rows = EdiRecords.query(TABLE, filter);
return (rows.stream().min((a, b) -> Integer.compare(a.getValueInteger("id"), b.getValueInteger("id"))).orElse(null));
}
/*******************************************************************************
** Parse the certificate, read its private key from the environment and prove
** the two belong together by signing and verifying a probe.
*******************************************************************************/
private LocalKey load(QRecord row) throws As2Exception
{
X509Certificate certificate = parse(row.getValueString("certificatePem"));
String variable = row.getValueString("privateKeyEnvVar");
if(variable == null || variable.isBlank())
{
throw (new As2Exception("Certificate " + row.getValueString("alias") + " has no privateKeyEnvVar"));
}
PrivateKey key = readKey(variable, row.getValueString("privateKeyPasswordEnvVar"));
if(!matches(key, certificate))
{
throw (new As2Exception("The private key in environment variable " + variable + " does not match certificate " + row.getValueString("alias")));
}
return (new LocalKey(row, certificate, key));
}
/*******************************************************************************
** Read the key from the named environment variable; failures name only the
** variable.
*******************************************************************************/
private static PrivateKey readKey(String variable, String passwordVariable) throws As2Exception
{
var secrets = EdiRuntime.current().config().getSecretsResolver();
String value = secrets.resolve(variable);
if(value == null || value.isBlank())
{
throw (new As2Exception("environment variable " + variable + " is not set"));
}
char[] password = new char[0];
if(passwordVariable != null && !passwordVariable.isBlank())
{
String resolved = secrets.resolve(passwordVariable);
if(resolved == null)
{
throw (new As2Exception("environment variable " + passwordVariable + " is not set"));
}
password = resolved.toCharArray();
}
try
{
return (parseKey(value, password));
}
catch(Exception e)
{
throw (new As2Exception("The private key in environment variable " + variable + " could not be read as a PEM PKCS#8 key or a base64 PKCS#12 store"));
}
}
/*******************************************************************************
** Parse a PEM key (PKCS#8, encrypted PKCS#8 or traditional) or a base64 PKCS#12
** store, entirely in memory.
*******************************************************************************/
static PrivateKey parseKey(String raw, char[] password) throws Exception
{
String text = raw.trim();
if(text.indexOf('\n') < 0 && text.contains("\\n"))
{
text = text.replace("\\n", "\n");
}
if(text.contains("-----BEGIN"))
{
JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
try(PEMParser parser = new PEMParser(new StringReader(text)))
{
Object object = parser.readObject();
while(object != null)
{
if(object instanceof PrivateKeyInfo info)
{
return (converter.getPrivateKey(info));
}
if(object instanceof PEMKeyPair pair)
{
return (converter.getKeyPair(pair).getPrivate());
}
if(object instanceof PKCS8EncryptedPrivateKeyInfo encrypted)
{
return (converter.getPrivateKey(encrypted.decryptPrivateKeyInfo(new JceOpenSSLPKCS8DecryptorProviderBuilder().build(password))));
}
object = parser.readObject();
}
}
throw (new IllegalArgumentException("no private key block"));
}
KeyStore store = KeyStore.getInstance("PKCS12");
store.load(new ByteArrayInputStream(Base64.getMimeDecoder().decode(text)), password);
for(String alias : Collections.list(store.aliases()))
{
if(store.isKeyEntry(alias))
{
return ((PrivateKey) store.getKey(alias, password));
}
}
throw (new IllegalArgumentException("no key entry"));
}
/*******************************************************************************
** Sign a probe with the key and verify it with the certificate public key.
*******************************************************************************/
private static boolean matches(PrivateKey key, X509Certificate certificate)
{
try
{
String algorithm = "EC".equals(key.getAlgorithm()) ? "SHA256withECDSA" : "SHA256with" + key.getAlgorithm();
byte[] probe = "qbit-edi-key-match".getBytes(StandardCharsets.UTF_8);
Signature signer = Signature.getInstance(algorithm);
signer.initSign(key);
signer.update(probe);
byte[] signature = signer.sign();
Signature verifier = Signature.getInstance(algorithm);
verifier.initVerify(certificate.getPublicKey());
verifier.update(probe);
return (verifier.verify(signature));
}
catch(Exception e)
{
return (false);
}
}
}
@@ -1,145 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Five-minute acknowledgement and public-certificate expiry monitor.
*******************************************************************************/
public class AckMonitor
{
/*******************************************************************************
** Deduplicate alerts and open exceptions without embedding document values.
*******************************************************************************/
public Integer runOnce() throws QException
{
return (SystemWrites.call(() ->
{
int count = 0;
var now = EdiRuntime.current().config().getClock().instant();
for(QRecord group : EdiRecords.query("edi_functional_group", EdiRecords.equal("ackStatus", "PENDING")
.withCriteria("ackDueAt", QCriteriaOperator.LESS_THAN, now).withCriteria("ackReceivedAt", QCriteriaOperator.IS_BLANK)))
{
QRecord interchange = OutboundWorker.row("edi_interchange", group.getValue("interchangeId"));
if(!"OUTBOUND".equals(interchange.getValueString("direction")))
{
continue;
}
String key = "edi_functional_group:" + group.getValue("id");
QAlerts.raise(new AlertSignal("edi.ack.overdue").withSeverity(AlertSeverity.WARNING).withDedupKey(key)
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_functional_group", group.getValue("id")))
.withTitle("EDI acknowledgement overdue").withSummary("Review the outbound functional group"));
if(EdiRecords.query("edi_exception", EdiRecords.equal("interchangeId", interchange.getValue("id")).withCriteria("type", QCriteriaOperator.EQUALS, "OVERDUE_ACK")
.withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")).isEmpty())
{
EdiJournal.exception(null, interchange.getValueInteger("id"), null, interchange.getValueInteger("partnerId"), "OVERDUE_ACK", "Expected acknowledgement has not arrived");
}
count++;
}
for(QRecord certificate : EdiRecords.query("edi_certificate", new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "ACTIVE", "NEXT")))
{
if(certificate.getValueInstant("notAfter") == null)
{
continue;
}
long days = ChronoUnit.DAYS.between(now, certificate.getValueInstant("notAfter"));
if(!certificate.getValueInstant("notAfter").isAfter(now))
{
expiry(certificate, "edi.certificate.expired", "expired", AlertSeverity.ERROR);
}
else
{
for(int threshold : List.of(30, 14, 7))
{
if(days < threshold)
{
expiry(certificate, "edi.certificate.expiring", String.valueOf(threshold), AlertSeverity.WARNING);
}
}
}
}
return (count + overdueMdns(now));
}));
}
/*******************************************************************************
** SENT messages on an asynchronous-MDN AS2 transport whose MDN has not arrived
** within the agreement document ackDueMinutes (60 when none is set) raise a
** warning, deduplicated per message and resolved when the MDN arrives.
*******************************************************************************/
private static int overdueMdns(java.time.Instant now) throws QException
{
List<java.io.Serializable> transports = new java.util.ArrayList<>();
for(QRecord transport : EdiRecords.query("edi_transport", EdiRecords.equal("type", "AS2").withCriteria("mdnMode", QCriteriaOperator.EQUALS, "ASYNC")))
{
transports.add(transport.getValue("id"));
}
if(transports.isEmpty())
{
return (0);
}
int count = 0;
for(QRecord message : EdiRecords.query("edi_outbound_message", EdiRecords.equal("status", "SENT").withCriteria("transportId", QCriteriaOperator.IN, transports)
.withCriteria("sentAt", QCriteriaOperator.IS_NOT_BLANK)))
{
int minutes = 60;
Integer smallest = null;
for(QRecord document : message.getValue("agreementId") == null ? List.<QRecord>of()
: EdiRecords.query("edi_agreement_document", EdiRecords.equal("agreementId", message.getValue("agreementId")).withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND")))
{
Integer due = document.getValueInteger("ackDueMinutes");
smallest = due == null ? smallest : smallest == null ? due : Math.min(smallest, due);
}
minutes = smallest == null ? minutes : smallest;
if(message.getValueInstant("sentAt").plus(minutes, ChronoUnit.MINUTES).isAfter(now))
{
continue;
}
QAlerts.raise(new AlertSignal("edi.mdn.overdue").withSeverity(AlertSeverity.WARNING).withDedupKey("edi_outbound_message:" + message.getValue("id"))
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_outbound_message", message.getValue("id")))
.withTitle("AS2 receipt overdue").withSummary("The asynchronous MDN has not arrived; review the outbound message"));
count++;
}
return (count);
}
/*******************************************************************************
** Each certificate threshold has its own stable deduplication key.
*******************************************************************************/
private static void expiry(QRecord certificate, String type, String threshold, AlertSeverity severity)
{
QAlerts.raise(new AlertSignal(type).withSeverity(severity).withDedupKey("edi_certificate:" + certificate.getValue("id") + ":" + threshold)
.withRecordRef(new com.kingsrook.qqq.backend.core.alerts.AlertRecordRef("edi_certificate", certificate.getValue("id")))
.withTitle("EDI certificate expiry").withSummary("Review the certificate validity dates"));
}
}
@@ -1,69 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.util.List;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Value-free operational timeline and coalesced exception work queue.
*******************************************************************************/
public final class EdiJournal
{
/*******************************************************************************
** Events contain only step names, record references and timestamps.
*******************************************************************************/
public static void event(Integer inbox, Integer interchange, Integer transaction, String type) throws Exception
{
SystemWrites.insert("edi_event", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange)
.withValue("transactionId", transaction).withValue("at", EdiRuntime.current().config().getClock().instant())
.withValue("type", type).withValue("detail", type).withValue("userName", "edi"));
}
/*******************************************************************************
** Repeated failures of the same item increment occurrences without exposing values.
*******************************************************************************/
public static void exception(Integer inbox, Integer interchange, Integer transaction, Integer partner, String type, String summary) throws Exception
{
QQueryFilter filter = EdiRecords.equal("type", type);
for(var pair : List.of(new Object[] {"inboxId", inbox}, new Object[] {"interchangeId", interchange}, new Object[] {"transactionId", transaction}))
{
filter.withCriteria(pair[0].toString(), pair[1] == null ? QCriteriaOperator.IS_BLANK : QCriteriaOperator.EQUALS, (Integer) pair[1]);
}
List<QRecord> matches = EdiRecords.query("edi_exception", filter);
if(matches.isEmpty())
{
SystemWrites.insert("edi_exception", new QRecord().withValue("inboxId", inbox).withValue("interchangeId", interchange).withValue("transactionId", transaction)
.withValue("partnerId", partner).withValue("type", type).withValue("summary", summary).withValue("status", "OPEN").withValue("occurrences", 1));
}
else
{
QRecord row = matches.getFirst();
SystemWrites.update("edi_exception", new QRecord().withValue("id", row.getValue("id")).withValue("occurrences", row.getValueInteger("occurrences") + 1).withValue("status", "OPEN"));
}
event(inbox, interchange, transaction, "EXCEPTION");
}
}
@@ -1,38 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.io.InputStream;
/*******************************************************************************
** Transport-neutral stream. The receiving transport owns and closes the stream.
*******************************************************************************/
public record InboundPayload(String transportType, String externalId, String fileName, String remoteAddress,
InputStream stream, String gatewayUserId, String username)
{
/*******************************************************************************
** Payload without gateway identity.
*******************************************************************************/
public InboundPayload(String transportType, String externalId, String fileName, String remoteAddress, InputStream stream)
{
this(transportType, externalId, fileName, remoteAddress, stream, null, null);
}
}
@@ -1,590 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.ack.AckEnvelope;
import com.kingsrook.qbits.edi.ack.AckLevel;
import com.kingsrook.qbits.edi.guide.Guide;
import com.kingsrook.qbits.edi.guide.GuideRepository;
import com.kingsrook.qbits.edi.guide.ValidationFinding;
import com.kingsrook.qbits.edi.map.InboundApplier;
import com.kingsrook.qbits.edi.map.InboundMapper;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadKind;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.storage.PayloadStore;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12Interchange;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Routing, validation, acknowledgements and service-session application checkpoints.
*******************************************************************************/
public class InboundProcessor
{
private final QRecord inbox;
private final Instant lease;
private final PayloadStore store;
private final EdiRuntime runtime = EdiRuntime.current();
/*******************************************************************************
** One processor per claimed payload; database checkpoints fence every side effect.
*******************************************************************************/
public InboundProcessor(QRecord inbox, Instant lease, PayloadStore store)
{
this.inbox = inbox;
this.lease = lease;
this.store = store;
}
/*******************************************************************************
** Route before mapping; unknown and mismatched identities never reach target writes.
*******************************************************************************/
public void process(X12Interchange x12, int position) throws Exception
{
QRecord agreement = route(x12);
QRecord interchange = InboxWorker.fenced(inbox, lease, () -> interchange(x12, agreement, position));
Integer id = interchange.getValueInteger("id");
if(agreement == null)
{
problem(id, null, null, "UNKNOWN_PARTNER", "No active agreement matches the interchange identities and usage");
return;
}
Integer partner = agreement.getValueInteger("partnerId");
if(inbox.getValueString("username") != null)
{
var linked = EdiRecords.query("edi_partner", EdiRecords.equal("gatewayUsername", inbox.getValue("username")));
if(linked.stream().anyMatch(p -> !partner.equals(p.getValueInteger("id"))))
{
problem(id, null, partner, "PARTNER_MISMATCH", "Gateway identity does not match the routed trading partner");
return;
}
}
String code = x12.findings().stream().filter(f -> "TA1".equals(f.level())).map(f -> f.code()).findFirst().orElse("000");
boolean duplicate = "DUPLICATE".equals(interchange.getValueString("status"));
if(duplicate)
{
code = "025";
problem(id, null, partner, "DUPLICATE", "Duplicate interchange control number");
}
boolean ackInput = x12.serviceSegments().stream().anyMatch(s -> "TA1".equals(s.id())) || x12.groups().stream().anyMatch(g -> !g.transactions().isEmpty()) && x12.groups().stream().flatMap(g -> g.transactions().stream()).allMatch(t -> List.of("997", "999").contains(t.st().value(1)));
String ta1Code = code;
String policy = agreement.getValueString("sendTa1");
boolean send = !ackInput && ("ALWAYS".equals(policy) || "ON_REQUEST".equals(policy) && "1".equals(x12.isa().value(14))
|| "ON_ERROR".equals(policy) && !"000".equals(code));
if(duplicate && "REJECT_TA1".equals(agreement.getValueString("onDuplicate")))
{
send = true;
}
if(send)
{
InboxWorker.fenced(inbox, lease, () ->
{
if(EdiRecords.query("edi_outbound_message", EdiRecords.equal("ackForInterchangeId", id)).isEmpty())
{
queue(runtime.ta1().interchange(x12, control(agreement, "ISA"), ta1Code, LocalDateTime.now(runtime.config().getClock())), agreement, id, null, "TA1");
}
return (null);
});
}
if(duplicate && !"PROCESS".equals(agreement.getValueString("onDuplicate")))
{
return;
}
if(!"000".equals(code) && !duplicate)
{
problem(id, null, partner, "ENVELOPE", "Interchange envelope failed validation");
return;
}
int groupPosition = 0;
for(X12FunctionalGroup group : x12.groups())
{
int currentGroupPosition = ++groupPosition;
QRecord groupRow = InboxWorker.fenced(inbox, lease, () -> group(group, interchange, agreement, currentGroupPosition));
Map<String, List<ValidationFinding>> findings = new LinkedHashMap<>();
QRecord ackDocument = null;
int transactionPosition = 0;
for(X12TransactionSet transaction : group.transactions())
{
QRecord document = document(agreement, transaction, group);
if(document != null && !"NONE".equals(document.getValueString("ackExpectation")))
{
ackDocument = document;
}
QRecord configuredDocument = document;
int currentTransactionPosition = ++transactionPosition;
QRecord tx = InboxWorker.fenced(inbox, lease, () -> transaction(transaction, groupRow, interchange, configuredDocument, currentTransactionPosition));
if(List.of("997", "999").contains(transaction.st().value(1)))
{
continue;
}
document = revisions(document, tx);
QRecord effectiveDocument = document;
List<ValidationFinding> result = validate(transaction, group, document, groupRow);
findings.put(transaction.st().value(2), result);
InboxWorker.fenced(inbox, lease, () ->
{
validation(tx, result, effectiveDocument);
return (null);
});
if(document == null)
{
problem(id, tx.getValueInteger("id"), partner, "NOT_CONFIGURED", "Inbound document has no active agreement configuration");
continue;
}
if(result.stream().anyMatch(f -> "ERROR".equals(f.severity()) && f.snipLevel() <= 2))
{
continue;
}
if("T".equals(x12.isa().value(15)) && !Boolean.TRUE.equals(agreement.getValueBoolean("applyTestDocuments")))
{
continue;
}
apply(transaction, tx, document, agreement);
}
if(!ackInput && ackDocument != null)
{
QRecord config = ackDocument;
InboxWorker.fenced(inbox, lease, () ->
{
if(EdiRecords.query("edi_outbound_message", EdiRecords.equal("ackForGroupId", groupRow.getValue("id"))).isEmpty())
{
AckLevel level = AckLevel.valueOf(Objects.requireNonNullElse(config.getValueString("ackLevel"), "ERRORS_ONLY"));
boolean hipaa = Boolean.TRUE.equals(config.getValueBoolean("containsPhi")) && group.gs().value(8).startsWith("005010X");
var ack = hipaa ? runtime.ack999().build(group, findings, level, "0001") : runtime.ack997().build(group, findings, level, "0001");
queue(AckEnvelope.wrap(x12, group, ack, control(agreement, "ISA"), control(agreement, "GS"), LocalDateTime.now(runtime.config().getClock())),
agreement, id, groupRow.getValueInteger("id"), hipaa ? "999" : "997");
}
return (null);
});
}
}
if(ackInput)
{
InboxWorker.fenced(inbox, lease, () ->
{
if(x12.serviceSegments().stream().anyMatch(s -> "TA1".equals(s.id()))
&& EdiRecords.query("edi_transaction", EdiRecords.equal("interchangeId", id)).isEmpty())
{
SystemWrites.insert("edi_transaction", new QRecord().withValue("interchangeId", id).withValue("partnerId", partner)
.withValue("direction", "INBOUND").withValue("transactionSet", "TA1").withValue("controlNumber", x12.isa().value(13)).withValue("status", "RECEIVED"));
}
reconcile(x12, agreement, id);
return (null);
});
}
}
/*******************************************************************************
** Route on sender, receiver, qualifier, standard and test/production indicator.
*******************************************************************************/
private QRecord route(X12Interchange x12) throws Exception
{
for(QRecord agreement : EdiRecords.query("edi_agreement", EdiRecords.equal("isActive", true).withCriteria("standard", QCriteriaOperator.EQUALS, "X12")
.withCriteria("usageIndicator", QCriteriaOperator.EQUALS, x12.isa().value(15))))
{
QRecord sender = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
QRecord receiver = row("edi_identity", agreement.getValueInteger("localIdentityId"));
if(sender != null && receiver != null && Objects.equals(sender.getValueInteger("partnerId"), agreement.getValueInteger("partnerId"))
&& receiver.getValue("partnerId") == null && x12.isa().value(5).equals(sender.getValueString("interchangeQualifier"))
&& x12.isa().value(6).trim().equals(sender.getValueString("interchangeId")) && x12.isa().value(7).equals(receiver.getValueString("interchangeQualifier"))
&& x12.isa().value(8).trim().equals(receiver.getValueString("interchangeId")))
{
return (agreement);
}
}
return (null);
}
/*******************************************************************************
** Persist each interchange once; serialize duplicate checks on its agreement.
*******************************************************************************/
private QRecord interchange(X12Interchange x12, QRecord agreement, int position) throws Exception
{
var prior = EdiRecords.query("edi_interchange", EdiRecords.equal("inboxId", inbox.getValue("id")).withCriteria("inboundPosition", QCriteriaOperator.EQUALS, position));
if(!prior.isEmpty())
{
QRecord existing = prior.getFirst();
if(agreement != null && existing.getValue("agreementId") == null)
{
existing.withValue("agreementId", agreement.getValue("id")).withValue("partnerId", agreement.getValue("partnerId"));
SystemWrites.update("edi_interchange", existing);
SystemWrites.update("edi_inbox", new QRecord().withValue("id", inbox.getValue("id")).withValue("partnerId", agreement.getValue("partnerId")));
}
return (existing);
}
boolean duplicate = false;
if(agreement != null)
{
SystemWrites.guard("UPDATE edi_agreement SET id=id WHERE id=?", agreement.getValue("id"));
duplicate = runtime.duplicates().interchange(agreement.getValueInteger("id"), x12.isa(), agreement.getValueInteger("duplicateWindowDays"), runtime.config().getClock().instant());
SystemWrites.update("edi_inbox", new QRecord().withValue("id", inbox.getValue("id")).withValue("partnerId", agreement.getValue("partnerId")));
}
QRecord row = SystemWrites.insert("edi_interchange", new QRecord().withValue("inboundPosition", position).withValue("inboxId", inbox.getValue("id")).withValue("direction", "INBOUND")
.withValue("agreementId", agreement == null ? null : agreement.getValue("id")).withValue("partnerId", agreement == null ? null : agreement.getValue("partnerId"))
.withValue("standard", "X12").withValue("senderQualifier", x12.isa().value(5)).withValue("senderId", x12.isa().value(6).trim())
.withValue("receiverQualifier", x12.isa().value(7)).withValue("receiverId", x12.isa().value(8).trim()).withValue("controlNumber", x12.isa().value(13))
.withValue("interchangeVersion", x12.isa().value(12)).withValue("usageIndicator", x12.isa().value(15)).withValue("groupCount", x12.groups().size())
.withValue("payloadRef", inbox.getValue("payloadRef")).withValue("payloadSha256", inbox.getValue("payloadSha256")).withValue("sizeBytes", inbox.getValue("sizeBytes"))
.withValue("status", duplicate ? "DUPLICATE" : x12.findings().isEmpty() ? "ACCEPTED" : "REJECTED"));
EdiJournal.event(inbox.getValueInteger("id"), row.getValueInteger("id"), null, "PARSED");
return (row);
}
/*******************************************************************************
** Group checkpoints retain sender and control duplicate evidence.
*******************************************************************************/
private QRecord group(X12FunctionalGroup group, QRecord interchange, QRecord agreement, int position) throws Exception
{
QQueryFilter filter = EdiRecords.equal("interchangeId", interchange.getValue("id")).withCriteria("inboundPosition", QCriteriaOperator.EQUALS, position);
var prior = EdiRecords.query("edi_functional_group", filter);
if(!prior.isEmpty())
{
return (prior.getFirst());
}
boolean duplicate = runtime.duplicates().group(group.gs().value(2), group.gs().value(6), agreement.getValueInteger("duplicateWindowDays"), runtime.config().getClock().instant());
return (SystemWrites.insert("edi_functional_group", new QRecord().withValue("inboundPosition", position).withValue("interchangeId", interchange.getValue("id")).withValue("senderId", group.gs().value(2))
.withValue("functionalIdCode", group.gs().value(1)).withValue("controlNumber", group.gs().value(6)).withValue("version", group.gs().value(8))
.withValue("transactionCount", group.transactions().size()).withValue("ackStatus", duplicate && !"PROCESS".equals(agreement.getValueString("onDuplicate")) ? "R" : "NONE")));
}
/*******************************************************************************
** Select the inbound document; outbound ack expectation never controls our reply.
*******************************************************************************/
private QRecord document(QRecord agreement, X12TransactionSet tx, X12FunctionalGroup group) throws Exception
{
var rows = EdiRecords.query("edi_agreement_document", EdiRecords.equal("agreementId", agreement.getValue("id"))
.withCriteria("direction", QCriteriaOperator.EQUALS, "INBOUND").withCriteria("transactionSet", QCriteriaOperator.EQUALS, tx.st().value(1))
.withCriteria("version", QCriteriaOperator.EQUALS, group.gs().value(8)).withCriteria("isActive", QCriteriaOperator.EQUALS, true));
return (rows.isEmpty() ? null : rows.getFirst());
}
/*******************************************************************************
** Transaction checkpoints preserve guide and map references across worker retries.
*******************************************************************************/
private QRecord transaction(X12TransactionSet tx, QRecord group, QRecord interchange, QRecord document, int position) throws Exception
{
var rows = EdiRecords.query("edi_transaction", EdiRecords.equal("functionalGroupId", group.getValue("id")).withCriteria("inboundPosition", QCriteriaOperator.EQUALS, position));
if(!rows.isEmpty())
{
return (rows.getFirst());
}
return (SystemWrites.insert("edi_transaction", new QRecord().withValue("inboundPosition", position).withValue("interchangeId", interchange.getValue("id")).withValue("functionalGroupId", group.getValue("id"))
.withValue("direction", "INBOUND").withValue("partnerId", interchange.getValue("partnerId")).withValue("transactionSet", tx.st().value(1))
.withValue("version", group.getValue("version")).withValue("controlNumber", tx.st().value(2)).withValue("segmentCount", tx.segments().size())
.withValue("agreementDocumentId", document == null ? null : document.getValue("id")).withValue("containsPhi", document != null && Boolean.TRUE.equals(document.getValueBoolean("containsPhi")))
.withValue("status", "RECEIVED")));
}
/*******************************************************************************
** Replays select original persisted definitions or the currently active revision.
*******************************************************************************/
private QRecord revisions(QRecord document, QRecord transaction) throws Exception
{
if(document == null)
{
return (null);
}
QRecord effective = new QRecord();
effective.setValues(new java.util.HashMap<>(document.getValues()));
for(String kind : List.of("guide", "map"))
{
Integer originalId = transaction.getValueInteger(kind + "Id");
boolean current = Boolean.TRUE.equals(inbox.getValueBoolean("useCurrentRevisions"));
if(originalId != null && !current)
{
effective.withValue(kind + "Id", originalId);
}
else if(current)
{
QRecord referenced = row("edi_" + kind, document.getValueInteger(kind + "Id"));
if(referenced != null)
{
var active = EdiRecords.query("edi_" + kind, EdiRecords.equal("name", referenced.getValue("name")).withCriteria("status", QCriteriaOperator.EQUALS, "ACTIVE"));
active.stream().filter(r -> Objects.equals(r.getValue("partnerId"), referenced.getValue("partnerId"))).findFirst()
.ifPresent(r -> effective.withValue(kind + "Id", r.getValue("id")));
}
}
}
return (effective);
}
/*******************************************************************************
** Gather positional findings, including group and repeated transaction controls.
*******************************************************************************/
private List<ValidationFinding> validate(X12TransactionSet tx, X12FunctionalGroup group, QRecord document, QRecord groupRow) throws Exception
{
List<ValidationFinding> findings = new ArrayList<>();
if(document != null && document.getValueInteger("guideId") != null)
{
findings.addAll(runtime.validator().validate(tx, new GuideRepository().load(document.getValueInteger("guideId")), Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))));
int snip = Objects.requireNonNullElse(document.getValueInteger("snipLevel"), 2);
findings.removeIf(f -> f.snipLevel() > snip);
}
else
{
findings.add(finding("No implementation guide is configured", "3"));
}
if("R".equals(groupRow.getValueString("ackStatus")) || runtime.duplicates().transactions(group).contains(tx.st().value(2)))
{
findings.add(finding("Duplicate control number", "7"));
}
if(!group.findings().isEmpty() || !tx.findings().isEmpty())
{
findings.add(finding("Envelope counts or controls failed validation", "7"));
}
return (findings);
}
/*******************************************************************************
** Construct a value-free structural error.
*******************************************************************************/
private static ValidationFinding finding(String message, String code)
{
return (new ValidationFinding(1, "ST", null, null, null, 1, code, null, code, null, "ERROR", message, null));
}
/*******************************************************************************
** Persist findings without repeatedly adding error rows on storage retries.
*******************************************************************************/
private void validation(QRecord tx, List<ValidationFinding> findings, QRecord document) throws Exception
{
boolean rejected = findings.stream().anyMatch(f -> "ERROR".equals(f.severity()) && f.snipLevel() <= 2);
if(!"APPLIED".equals(tx.getValueString("status")) || Boolean.TRUE.equals(inbox.getValueBoolean("reprocess")))
{
QRecord guide = document == null ? null : row("edi_guide", document.getValueInteger("guideId"));
SystemWrites.update("edi_transaction", new QRecord().withValue("id", tx.getValue("id")).withValue("status", rejected ? "INVALID" : "VALID")
.withValue("ackStatus", rejected ? "R" : findings.isEmpty() ? "A" : "E").withValue("errorCount", (int) findings.stream().filter(f -> "ERROR".equals(f.severity())).count())
.withValue("warningCount", (int) findings.stream().filter(f -> !"ERROR".equals(f.severity())).count())
.withValue("guideId", guide == null ? null : guide.getValue("id")).withValue("guideRevision", guide == null ? null : guide.getValue("revision"))
.withValue("mapId", document == null ? null : document.getValue("mapId"))
.withValue("mapRevision", document == null || row("edi_map", document.getValueInteger("mapId")) == null ? null : row("edi_map", document.getValueInteger("mapId")).getValue("revision")));
}
if(Boolean.TRUE.equals(inbox.getValueBoolean("reprocess")))
{
new com.kingsrook.qqq.backend.core.actions.tables.DeleteAction().execute(new com.kingsrook.qqq.backend.core.model.actions.tables.delete.DeleteInput("edi_transaction_error")
.withQueryFilter(EdiRecords.equal("transactionId", tx.getValue("id"))).withTransaction(com.kingsrook.qqq.backend.core.context.QContext.getQBackendTransaction()));
}
if(EdiRecords.query("edi_transaction_error", EdiRecords.equal("transactionId", tx.getValue("id"))).isEmpty())
{
for(ValidationFinding f : findings)
{
SystemWrites.insert("edi_transaction_error", new QRecord().withValue("transactionId", tx.getValue("id")).withValue("segmentPosition", f.segmentPosition())
.withValue("segmentId", f.segmentId()).withValue("loopId", f.loopId()).withValue("elementPosition", f.elementPosition()).withValue("componentPosition", f.componentPosition())
.withValue("snipLevel", f.snipLevel()).withValue("ackCode", f.ak4Code() == null ? f.ak3Code() : f.ak4Code()).withValue("severity", f.severity())
.withValue("message", f.message()).withValue("valueExcerpt", Boolean.TRUE.equals(tx.getValueBoolean("containsPhi")) ? null : f.valueExcerpt()));
}
}
EdiJournal.event(inbox.getValueInteger("id"), tx.getValueInteger("interchangeId"), tx.getValueInteger("id"), "VALIDATED");
if(!findings.isEmpty())
{
EdiJournal.exception(inbox.getValueInteger("id"), tx.getValueInteger("interchangeId"), tx.getValueInteger("id"), tx.getValueInteger("partnerId"), "VALIDATION", "Document validation has findings");
}
}
/*******************************************************************************
** A failed target write rolls back only this set, then records an APPLY exception.
*******************************************************************************/
private void apply(X12TransactionSet x12, QRecord tx, QRecord document, QRecord agreement) throws Exception
{
if("APPLIED".equals(tx.getValueString("status")) && !Boolean.TRUE.equals(inbox.getValueBoolean("reprocess")))
{
return;
}
QRecord map = row("edi_map", document.getValueInteger("mapId"));
if(map == null || !("ACTIVE".equals(map.getValueString("status")) || Boolean.TRUE.equals(inbox.getValueBoolean("reprocess")) && !Boolean.TRUE.equals(inbox.getValueBoolean("useCurrentRevisions"))))
{
return;
}
String[] stage = {"MAPPING"};
try
{
InboxWorker.fenced(inbox, lease, () ->
{
Guide guide = new GuideRepository().load(document.getValueInteger("guideId"));
List<QRecord> rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
if(runtime.config().getServiceSessionSupplier() == null)
{
stage[0] = "APPLY";
}
InboundMapper.Result mapped = InboundApplier.asPartner(runtime.config().getServiceSessionSupplier(), agreement.getValueInteger("partnerId"), () -> new InboundMapper(runtime.transforms()).map(x12, guide, map, rules));
stage[0] = "APPLY";
QRecord target = InboundApplier.asPartner(runtime.config().getServiceSessionSupplier(), agreement.getValueInteger("partnerId"), () -> new InboundApplier().apply(map, mapped.record()));
String primaryKey = com.kingsrook.qqq.backend.core.context.QContext.getQInstance().getTable(map.getValueString("tableName")).getPrimaryKeyField();
boolean phi = Boolean.TRUE.equals(document.getValueBoolean("containsPhi"));
SystemWrites.update("edi_transaction", new QRecord().withValue("id", tx.getValue("id")).withValue("status", "APPLIED")
.withValue("mapId", map.getValue("id")).withValue("mapRevision", map.getValue("revision")).withValue("businessKeyType", map.getValue("businessKeyType"))
.withValue(phi ? "phiBusinessKey" : "businessKey", mapped.businessKey()).withValue("secondaryKey", phi ? null : mapped.secondaryKey())
.withValue("targetTableName", map.getValue("tableName")).withValue("targetRecordId", target.getValueString(primaryKey)));
EdiJournal.event(inbox.getValueInteger("id"), tx.getValueInteger("interchangeId"), tx.getValueInteger("id"), "MAPPED");
EdiJournal.event(inbox.getValueInteger("id"), tx.getValueInteger("interchangeId"), tx.getValueInteger("id"), "APPLIED");
return (null);
});
}
catch(Exception e)
{
InboxWorker.fenced(inbox, lease, () ->
{
SystemWrites.update("edi_transaction", new QRecord().withValue("id", tx.getValue("id")).withValue("status", "EXCEPTION"));
EdiJournal.exception(inbox.getValueInteger("id"), tx.getValueInteger("interchangeId"), tx.getValueInteger("id"), agreement.getValueInteger("partnerId"), stage[0],
runtime.config().getServiceSessionSupplier() == null ? "no service session configured" : "Inbound " + stage[0].toLowerCase(java.util.Locale.ROOT) + " failed");
return (null);
});
}
}
/*******************************************************************************
** Allocate controls from the agreement or local identity's configured scope.
*******************************************************************************/
private String control(QRecord agreement, String level) throws Exception
{
boolean local = "LOCAL_IDENTITY".equals(agreement.getValueString("controlNumberScope"));
return (String.valueOf(runtime.controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, level, 1, 999999999, true)));
}
/*******************************************************************************
** Persist an encrypted, fully enveloped acknowledgement; sending is phase b.
*******************************************************************************/
private void queue(X12Interchange ack, QRecord agreement, Integer receivedId, Integer groupId, String type) throws Exception
{
PayloadRef payload = store.put(PayloadKind.OUTBOUND, new ByteArrayInputStream(runtime.writer().write(ack).getBytes(StandardCharsets.US_ASCII)));
QRecord message = SystemWrites.insert("edi_outbound_message", new QRecord().withValue("agreementId", agreement.getValue("id")).withValue("partnerId", agreement.getValue("partnerId"))
.withValue("transportId", agreement.getValue("outboundTransportId")).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes())
.withValue("status", "QUEUED").withValue("attempts", 0).withValue("ackForGroupId", groupId).withValue("ackForInterchangeId", groupId == null ? receivedId : null));
QRecord interchange = SystemWrites.insert("edi_interchange", new QRecord().withValue("outboundMessageId", message.getValue("id")).withValue("direction", "OUTBOUND")
.withValue("agreementId", agreement.getValue("id")).withValue("partnerId", agreement.getValue("partnerId")).withValue("controlNumber", ack.isa().value(13))
.withValue("senderQualifier", ack.isa().value(5)).withValue("senderId", ack.isa().value(6).trim()).withValue("receiverQualifier", ack.isa().value(7)).withValue("receiverId", ack.isa().value(8).trim())
.withValue("standard", "X12").withValue("interchangeVersion", ack.isa().value(12)).withValue("usageIndicator", ack.isa().value(15)).withValue("groupCount", ack.groups().size())
.withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes()).withValue("status", "ENVELOPED"));
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getValue("id")).withValue("interchangeId", interchange.getValue("id")));
Integer outboundGroup = null;
if(!ack.groups().isEmpty())
{
var gs = ack.groups().getFirst().gs();
outboundGroup = SystemWrites.insert("edi_functional_group", new QRecord().withValue("interchangeId", interchange.getValue("id")).withValue("functionalIdCode", "FA")
.withValue("senderId", gs.value(2)).withValue("controlNumber", gs.value(6)).withValue("version", gs.value(8)).withValue("transactionCount", 1).withValue("ackStatus", "NONE")).getValueInteger("id");
}
SystemWrites.insert("edi_transaction", new QRecord().withValue("interchangeId", interchange.getValue("id")).withValue("functionalGroupId", outboundGroup)
.withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId")).withValue("transactionSet", type).withValue("status", "QUEUED")
.withValue("controlNumber", outboundGroup == null ? ack.isa().value(13) : "0001").withValue("version", ack.groups().isEmpty() ? ack.isa().value(12) : ack.groups().getFirst().gs().value(8)));
EdiJournal.event(inbox.getValueInteger("id"), receivedId, null, "ACK_QUEUED");
}
/*******************************************************************************
** Reconcile only outbound controls on this agreement; acknowledgements get no ack.
*******************************************************************************/
private void reconcile(X12Interchange x12, QRecord agreement, Integer received) throws Exception
{
for(var ack : runtime.acknowledgements().read(x12))
{
var receivedAcks = EdiRecords.query("edi_transaction", EdiRecords.equal("interchangeId", received).withCriteria("transactionSet", QCriteriaOperator.EQUALS, ack.type()));
Integer ackTransactionId = receivedAcks.isEmpty() ? null : receivedAcks.getFirst().getValueInteger("id");
if(ackTransactionId != null)
{
SystemWrites.update("edi_transaction", new QRecord().withValue("id", ackTransactionId).withValue("status", "ACKNOWLEDGED").withValue("ackStatus", ack.status()));
}
for(QRecord interchange : EdiRecords.query("edi_interchange", EdiRecords.equal("agreementId", agreement.getValue("id")).withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND")))
{
boolean ta1 = "TA1".equals(ack.type());
if(ta1 && !ack.controlNumber().equals(interchange.getValueString("controlNumber")))
{
continue;
}
if(ta1)
{
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchange.getValue("id")).withValue("ta1Status", ack.status()));
continue;
}
for(QRecord group : EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", interchange.getValue("id"))))
{
if(!ta1 && !ack.controlNumber().equals(group.getValueString("controlNumber")))
{
continue;
}
SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id")).withValue("ackStatus", ack.status()).withValue("ackReceivedAt", runtime.config().getClock().instant()).withValue("ackTransactionId", ackTransactionId));
for(QRecord transaction : EdiRecords.query("edi_transaction", EdiRecords.equal("functionalGroupId", group.getValue("id"))))
{
String status = ack.transactionStatuses().getOrDefault(transaction.getValueString("controlNumber"), ack.status());
SystemWrites.update("edi_transaction", new QRecord().withValue("id", transaction.getValue("id")).withValue("ackStatus", status)
.withValue("status", "R".equals(status) ? "REJECTED" : "ACKNOWLEDGED"));
}
QAlerts.resolve("edi.ack.overdue", "edi_functional_group:" + group.getValue("id"), "Acknowledgement received");
for(QRecord exception : EdiRecords.query("edi_exception", EdiRecords.equal("interchangeId", interchange.getValue("id"))
.withCriteria("type", QCriteriaOperator.EQUALS, "OVERDUE_ACK").withCriteria("status", QCriteriaOperator.IN, "OPEN", "IN_PROGRESS")))
{
SystemWrites.update("edi_exception", new QRecord().withValue("id", exception.getValue("id")).withValue("status", "RESOLVED")
.withValue("resolvedAt", runtime.config().getClock().instant()).withValue("resolution", "Acknowledgement received"));
}
if(List.of("R", "P").contains(ack.status()))
{
EdiJournal.exception(inbox.getValueInteger("id"), received, null, agreement.getValueInteger("partnerId"), "REJECTED_ACK", "Partner rejected an outbound group");
QAlerts.raise(new AlertSignal("edi.ack.rejected").withSeverity(AlertSeverity.ERROR).withDedupKey(group.getValueString("id"))
.withTitle("EDI acknowledgement rejected").withSummary("Review the outbound functional group"));
}
}
}
}
EdiJournal.event(inbox.getValueInteger("id"), received, null, "ACK_RECEIVED");
}
/*******************************************************************************
** Persist safe failures with the current claim held.
*******************************************************************************/
private void problem(Integer interchange, Integer transaction, Integer partner, String type, String summary) throws Exception
{
InboxWorker.fenced(inbox, lease, () ->
{
EdiJournal.exception(inbox.getValueInteger("id"), interchange, transaction, partner, type, summary);
return (null);
});
}
/*******************************************************************************
** Optional foreign-key lookup.
*******************************************************************************/
private static QRecord row(String table, Integer id) throws Exception
{
if(id == null)
{
return (null);
}
var rows = EdiRecords.query(table, EdiRecords.equal("id", id));
return (rows.isEmpty() ? null : rows.getFirst());
}
}
@@ -1,97 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadKind;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.storage.PayloadStore;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Store first, then acknowledge durable intake. Never parse in the receive path.
*******************************************************************************/
public class InboundSink
{
private final PayloadStore store;
/*******************************************************************************
** Configure the storage boundary.
*******************************************************************************/
public InboundSink(PayloadStore store)
{
this.store = store;
}
/*******************************************************************************
** Unique external ids survive retries; plaintext digests catch byte duplicates.
*******************************************************************************/
public QRecord receive(InboundPayload payload) throws Exception
{
if(payload.transportType() == null || payload.externalId() == null || payload.externalId().isBlank())
{
throw new IllegalArgumentException("Transport and external id are required");
}
return (SystemWrites.call(() ->
{
QQueryFilter key = EdiRecords.equal("transportType", payload.transportType()).withCriteria("externalId", QCriteriaOperator.EQUALS, payload.externalId());
List<QRecord> existing = EdiRecords.query("edi_inbox", key);
if(!existing.isEmpty())
{
return (existing.getFirst());
}
try
{
return (SystemWrites.transaction(() ->
{
PayloadRef ref = store.put(PayloadKind.INBOUND, payload.stream());
Instant now = EdiRuntime.current().config().getClock().instant();
boolean duplicate = !EdiRecords.query("edi_inbox", EdiRecords.equal("payloadSha256", ref.sha256())
.withCriteria("receivedAt", QCriteriaOperator.GREATER_THAN_OR_EQUALS, now.minus(365, ChronoUnit.DAYS))).isEmpty();
QRecord row = SystemWrites.insert("edi_inbox", new QRecord().withValue("transportType", payload.transportType()).withValue("externalId", payload.externalId())
.withValue("fileName", payload.fileName()).withValue("remoteAddress", payload.remoteAddress()).withValue("gatewayUserId", payload.gatewayUserId())
.withValue("username", payload.username()).withValue("payloadRef", ref.value()).withValue("payloadSha256", ref.sha256()).withValue("sizeBytes", ref.sizeBytes())
.withValue("receivedAt", now).withValue("status", duplicate ? "DUPLICATE" : "PENDING").withValue("attempts", 0).withValue("nextAttemptAt", now));
EdiJournal.event(row.getValueInteger("id"), null, null, "RECEIVED");
EdiJournal.event(row.getValueInteger("id"), null, null, "STORED");
return (row);
}));
}
catch(Exception e)
{
// A competing receiver may have committed the unique external id.
existing = EdiRecords.query("edi_inbox", key);
if(!existing.isEmpty())
{
return (existing.getFirst());
}
throw e;
}
}));
}
}
@@ -1,176 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.io.InputStream;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.storage.PayloadStore;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Bounded durable worker with database leases and independently committed sets.
*******************************************************************************/
public class InboxWorker
{
public static final String DEAD_LETTER_ALERT = "edi.inbox.dead-letter";
private static final List<Long> BACKOFF = List.of(60L, 300L, 900L, 3600L, 21600L);
private final PayloadStore store;
/*******************************************************************************
** Default runtime storage.
*******************************************************************************/
public InboxWorker()
{
this(EdiRuntime.current().payloads());
}
/*******************************************************************************
** Injectable storage boundary for transport/storage failure tests.
*******************************************************************************/
public InboxWorker(PayloadStore store)
{
this.store = store;
}
/*******************************************************************************
** Process at most twenty due messages as EDI's internal writer.
*******************************************************************************/
public Integer runOnce() throws QException
{
return (SystemWrites.call(() ->
{
Instant now = now();
QQueryFilter due = EdiRecords.equal("status", "RETRY_WAIT")
.withSubFilter(new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR)
.withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK).withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now));
QQueryFilter stale = EdiRecords.equal("status", "PROCESSING").withCriteria("claimedAt", QCriteriaOperator.LESS_THAN, now.minusSeconds(300));
List<QRecord> rows = EdiRecords.query("edi_inbox", new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withSubFilter(due).withSubFilter(EdiRecords.equal("status", "PENDING")).withSubFilter(stale)
.withOrderBy(new QFilterOrderBy("receivedAt")).withLimit(20));
int completed = 0;
for(QRecord row : rows)
{
Instant lease = now().truncatedTo(ChronoUnit.MILLIS);
if(!claim(row, lease))
{
continue;
}
try(InputStream stream = store.open(new PayloadRef(row.getValueString("payloadRef"), row.getValueString("payloadSha256"), row.getValueLong("sizeBytes"))))
{
var interchanges = EdiRuntime.current().reader().read(stream);
int position = 0;
for(var interchange : interchanges)
{
new InboundProcessor(row, lease, store).process(interchange, ++position);
}
fenced(row, lease, () ->
{
SystemWrites.update("edi_inbox", new QRecord().withValue("id", row.getValue("id")).withValue("status", "PROCESSED")
.withValue("processedAt", now()).withValue("nextAttemptAt", null).withValue("lastError", null).withValue("reprocess", false));
return (null);
});
completed++;
}
catch(Exception e)
{
fail(row, lease);
}
}
return (completed);
}));
}
/*******************************************************************************
** Compare selected status and lease before claiming; only the winner proceeds.
*******************************************************************************/
private boolean claim(QRecord row, Instant lease) throws QException
{
return (SystemWrites.transaction(() ->
{
boolean won = row.getValue("claimedAt") == null
? SystemWrites.guard("UPDATE edi_inbox SET id=id WHERE id=? AND status=? AND claimed_at IS NULL", row.getValue("id"), row.getValue("status"))
: SystemWrites.guard("UPDATE edi_inbox SET id=id WHERE id=? AND status=? AND claimed_at=?", row.getValue("id"), row.getValue("status"), row.getValueInstant("claimedAt"));
if(won)
{
SystemWrites.update("edi_inbox", new QRecord().withValue("id", row.getValue("id")).withValue("status", "PROCESSING").withValue("claimedAt", lease));
}
return (won);
}));
}
/*******************************************************************************
** Every checkpoint holds the inbox lock and verifies ownership until commit.
*******************************************************************************/
public static <T> T fenced(QRecord inbox, Instant lease, SystemWrites.Work<T> work) throws QException
{
return (SystemWrites.transaction(() ->
{
if(!SystemWrites.guard("UPDATE edi_inbox SET id=id WHERE id=? AND status='PROCESSING' AND claimed_at=?", inbox.getValue("id"), lease))
{
throw new QException("Inbox claim was lost");
}
return (work.run());
}));
}
/*******************************************************************************
** Persist retry outcome separately from failed set writes, with no exception text.
*******************************************************************************/
private void fail(QRecord row, Instant lease) throws QException
{
fenced(row, lease, () ->
{
int attempts = Objects.requireNonNullElse(row.getValueInteger("attempts"), 0) + 1;
boolean dead = attempts > BACKOFF.size();
SystemWrites.update("edi_inbox", new QRecord().withValue("id", row.getValue("id")).withValue("attempts", attempts)
.withValue("status", dead ? "DEAD_LETTER" : "RETRY_WAIT").withValue("nextAttemptAt", dead ? null : now().plusSeconds(BACKOFF.get(attempts - 1)))
.withValue("lastError", "Inbound processing failed; retry the stored payload"));
if(dead)
{
EdiJournal.exception(row.getValueInteger("id"), null, null, row.getValueInteger("partnerId"), "DEAD_LETTER", "Inbound processing exhausted retries");
QAlerts.raise(new AlertSignal(DEAD_LETTER_ALERT).withSeverity(AlertSeverity.ERROR).withDedupKey(row.getValueString("id"))
.withTitle("EDI inbox exhausted retries").withSummary("Review the dead-letter inbox record"));
}
return (null);
});
}
/*******************************************************************************
** Host clock makes retry and stale-claim behavior deterministic.
*******************************************************************************/
private static Instant now()
{
return (EdiRuntime.current().config().getClock().instant());
}
}
@@ -1,154 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.util.List;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Guarded resend/cancel operations used after the standard Review step.
*******************************************************************************/
public final class OutboundActions
{
/*******************************************************************************
** Call within SystemWrites.transaction so validation and mutation share a lock.
*******************************************************************************/
public static void execute(String table, QRecord selected, String operation) throws Exception
{
QRecord message = table.equals("edi_interchange") ? OutboundWorker.row("edi_outbound_message", selected.getValue("outboundMessageId")) : selected;
if(!SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status=?", message.getValue("id"), message.getValue("status")))
{
throw new IllegalStateException("Outbound message changed; refresh the record");
}
message = OutboundWorker.row("edi_outbound_message", message.getValue("id"));
if(operation.equals("cancel"))
{
if(!List.of("QUEUED", "RETRY_WAIT").contains(message.getValueString("status")))
{
throw new IllegalStateException("Only queued or retrying messages can be cancelled");
}
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getValue("id")).withValue("status", "CANCELLED").withValue("nextAttemptAt", null));
}
else if(operation.equals("resend"))
{
SystemWrites.update("edi_outbound_message", new QRecord().withValue("id", message.getValue("id")).withValue("status", "QUEUED")
.withValue("attempts", 0).withValue("nextAttemptAt", null).withValue("lastError", null));
}
else if(operation.equals("new"))
{
reEnvelope(message);
}
else
{
throw new IllegalArgumentException("Unknown outbound operation");
}
EdiJournal.event(null, message.getValueInteger("interchangeId"), null, operation.toUpperCase(java.util.Locale.ROOT));
}
/*******************************************************************************
** Re-envelope the archived payload and tracking snapshots, including TA1/997/999.
** Maps are not re-run: source edits require a new outbound request instead.
*******************************************************************************/
private static void reEnvelope(QRecord message) throws Exception
{
var runtime = EdiRuntime.current();
QRecord originalRow = OutboundWorker.row("edi_interchange", message.getValue("interchangeId"));
QRecord agreement = OutboundWorker.row("edi_agreement", message.getValue("agreementId"));
boolean local = "LOCAL_IDENTITY".equals(agreement.getValueString("controlNumberScope"));
String control = String.format("%09d", runtime.controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, "ISA", 1, 999999999, true));
com.kingsrook.qbits.edi.x12.X12Interchange original;
try(var stream = runtime.payloads().open(new PayloadRef(message.getValueString("payloadRef"), message.getValueString("payloadSha256"), message.getValueLong("sizeBytes"))))
{
original = runtime.reader().read(stream).getFirst();
}
var groups = new java.util.ArrayList<com.kingsrook.qbits.edi.x12.X12FunctionalGroup>();
for(var group : original.groups())
{
String groupControl = String.valueOf(runtime.controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, "GS", 1, 999999999, true));
groups.add(new com.kingsrook.qbits.edi.x12.X12FunctionalGroup(replace(group.gs(), 6, groupControl), replace(group.ge(), 2, groupControl), group.transactions(), List.of()));
}
var rebuilt = new com.kingsrook.qbits.edi.x12.X12Interchange(original.separators(), replace(original.isa(), 13, control), replace(original.iea(), 2, control), groups, original.serviceSegments(), List.of());
var payload = runtime.payloads().put(com.kingsrook.qbits.edi.storage.PayloadKind.OUTBOUND,
new java.io.ByteArrayInputStream(runtime.writer().write(rebuilt).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
QRecord newInterchange = copy(originalRow).withValue("originalInterchangeId", originalRow.getValue("id")).withValue("controlNumber", control)
.withValue("status", "ENVELOPED").withValue("ta1Status", null).withValue("outboundMessageId", null).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes());
newInterchange = SystemWrites.insert("edi_interchange", newInterchange);
var groupIds = new java.util.HashMap<Integer, Integer>();
for(QRecord oldGroup : EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", originalRow.getValue("id"))))
{
int index = -1;
for(int i = 0; i < original.groups().size(); i++)
{
if(original.groups().get(i).gs().value(6).equals(oldGroup.getValueString("controlNumber")))
{
index = i;
break;
}
}
if(index < 0)
{
throw new IllegalStateException("Stored group does not match archived envelope");
}
QRecord newGroup = SystemWrites.insert("edi_functional_group", copy(oldGroup).withValue("interchangeId", newInterchange.getValue("id"))
.withValue("controlNumber", groups.get(index).gs().value(6)).withValue("ackDueAt", null).withValue("ackReceivedAt", null).withValue("ackTransactionId", null)
.withValue("ackStatus", "NONE".equals(oldGroup.getValueString("ackStatus")) ? "NONE" : "PENDING"));
groupIds.put(oldGroup.getValueInteger("id"), newGroup.getValueInteger("id"));
}
for(QRecord transaction : EdiRecords.query("edi_transaction", EdiRecords.equal("interchangeId", originalRow.getValue("id"))))
{
SystemWrites.insert("edi_transaction", copy(transaction).withValue("interchangeId", newInterchange.getValue("id"))
.withValue("functionalGroupId", groupIds.get(transaction.getValueInteger("functionalGroupId"))).withValue("status", "QUEUED").withValue("ackStatus", null));
}
QRecord newMessage = SystemWrites.insert("edi_outbound_message", copy(message).withValue("interchangeId", newInterchange.getValue("id"))
.withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes())
.withValue("status", "QUEUED").withValue("attempts", 0).withValue("nextAttemptAt", null).withValue("sentAt", null).withValue("deliveredAt", null).withValue("claimedAt", null)
.withValue("transportMessageId", null).withValue("lastError", null).withValue("ackForGroupId", null).withValue("ackForInterchangeId", null));
SystemWrites.update("edi_interchange", new QRecord().withValue("id", newInterchange.getValue("id")).withValue("outboundMessageId", newMessage.getValue("id")));
}
/*******************************************************************************
** Preserve components and repetitions while replacing one envelope element.
*******************************************************************************/
private static com.kingsrook.qbits.edi.x12.X12Segment replace(com.kingsrook.qbits.edi.x12.X12Segment segment, int position, String value)
{
var elements = new java.util.ArrayList<>(segment.elements());
elements.set(position - 1, List.of(List.of(value)));
return (new com.kingsrook.qbits.edi.x12.X12Segment(segment.id(), segment.position(), elements));
}
/*******************************************************************************
** Clone business metadata without identity and audit timestamps.
*******************************************************************************/
private static QRecord copy(QRecord source)
{
QRecord copy = new QRecord();
copy.getValues().putAll(source.getValues());
for(String field : List.of("id", "createDate", "modifyDate"))
{
copy.getValues().remove(field);
}
return (copy);
}
}
@@ -1,116 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeEvent;
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeListenerInterface;
import com.kingsrook.qqq.backend.core.actions.tables.listeners.RecordChangeType;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Transactional source listener and explicit enqueue boundary for MANUAL/ESB.
*******************************************************************************/
public class OutboundEnqueue implements RecordChangeListenerInterface
{
/*******************************************************************************
** Ignore internal tables and deletes; definitions are read at event time.
*******************************************************************************/
public boolean appliesTo(String table, RecordChangeType type)
{
return (!table.startsWith("edi_") && type != RecordChangeType.DELETE);
}
/*******************************************************************************
** Use the event transaction, even when the caller did not put it in QContext.
*******************************************************************************/
public void onRecordsChanged(RecordChangeEvent event) throws QException
{
var previous = QContext.getQBackendTransaction();
QContext.setTransaction(event.getTransaction());
try
{
for(QRecord record : event.getRecords())
{
enqueue(event.getTableName(), record, "RECORD_CHANGE", null);
}
}
finally
{
QContext.setTransaction(previous);
}
}
/*******************************************************************************
** Enqueue matching active outbound definitions using the writer's transaction.
*******************************************************************************/
public static Integer enqueue(String table, QRecord source, String trigger, String eventId) throws QException
{
return (SystemWrites.call(() ->
{
int count = 0;
var definitions = EdiRecords.query("edi_agreement_document", EdiRecords.equal("sourceTableName", table)
.withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true));
for(QRecord document : definitions)
{
if(!"MANUAL".equals(trigger) && !trigger.equals(document.getValueString("outboundTrigger")))
{
continue;
}
QRecord agreement = OutboundWorker.row("edi_agreement", document.getValueInteger("agreementId"));
if(!Boolean.TRUE.equals(agreement.getValueBoolean("isActive")))
{
continue;
}
var metadata = QContext.getQInstance().getTable(table);
if(!metadata.getBackendName().equals(EdiRuntime.current().config().getBackendName()))
{
throw new QException("Outbound source and EDI must share the transactional backend");
}
String filter = document.getValueString("triggerFilter");
if(filter != null && !filter.isBlank() && EdiRecords.query(table, EdiRecords.equal(metadata.getPrimaryKeyField(), source.getValue(metadata.getPrimaryKeyField()))
.withSubFilter(new ObjectMapper().readValue(filter, QQueryFilter.class))).isEmpty())
{
continue;
}
if(eventId != null && !EdiRecords.query("edi_outbound_request", EdiRecords.equal("eventId", eventId)
.withCriteria("agreementDocumentId", QCriteriaOperator.EQUALS, document.getValue("id"))).isEmpty())
{
continue;
}
SystemWrites.insert("edi_outbound_request", new QRecord().withValue("agreementDocumentId", document.getValue("id"))
.withValue("sourceTableName", table).withValue("sourceRecordId", source.getValueString(metadata.getPrimaryKeyField()))
.withValue("eventId", eventId).withValue("status", "PENDING").withValue("attempts", 0)
.withValue("nextAttemptAt", EdiRuntime.current().config().getClock().instant()));
count++;
}
return (count);
}));
}
}
@@ -1,260 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.io.ByteArrayInputStream;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.guide.GuideRepository;
import com.kingsrook.qbits.edi.map.InboundApplier;
import com.kingsrook.qbits.edi.map.OutboundMapper;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadKind;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qbits.edi.x12.X12FunctionalGroup;
import com.kingsrook.qbits.edi.x12.X12Interchange;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12Separators;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Transactional map/validate/envelope worker. Row locks fence replicas; a crash
** rolls the whole envelope back, leaving requests available for the next run.
*******************************************************************************/
public class OutboundWorker
{
/*******************************************************************************
** Process immediate definitions; batch processes call runDocument explicitly.
*******************************************************************************/
public Integer runOnce() throws QException
{
return (SystemWrites.call(() ->
{
int count = 0;
for(QRecord document : EdiRecords.query("edi_agreement_document", EdiRecords.equal("direction", "OUTBOUND").withCriteria("isActive", QCriteriaOperator.EQUALS, true)))
{
if(!"BATCH".equals(document.getValueString("batchMode")))
{
count += runDocument(document.getValueInteger("id"));
}
}
return (count);
}));
}
/*******************************************************************************
** A scheduled batch invocation cuts one bounded batch for its definition.
*******************************************************************************/
public Integer runDocument(Integer id) throws QException
{
return (SystemWrites.call(() -> SystemWrites.transaction(() ->
{
QRecord document = row("edi_agreement_document", id);
QRecord agreement = row("edi_agreement", document.getValueInteger("agreementId"));
if(!Boolean.TRUE.equals(document.getValueBoolean("isActive")) || !Boolean.TRUE.equals(agreement.getValueBoolean("isActive")))
{
return (0);
}
var requests = EdiRecords.query("edi_outbound_request", EdiRecords.equal("agreementDocumentId", id).withCriteria("status", QCriteriaOperator.EQUALS, "PENDING").withLimit(100));
if(requests.isEmpty())
{
return (0);
}
List<QRecord> accepted = new ArrayList<>();
List<X12TransactionSet> sets = new ArrayList<>();
QRecord map = row("edi_map", document.getValueInteger("mapId"));
var rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
var associations = new java.util.LinkedHashSet<String>();
for(QRecord rule : rules)
{
String path = rule.getValueString("qqqPath");
if(path != null)
{
for(int dot = path.indexOf('.'); dot >= 0; dot = path.indexOf('.', dot + 1))
{
associations.add(path.substring(0, dot));
}
}
}
var guide = new GuideRepository().load(document.getValueInteger("guideId"));
for(QRecord request : requests)
{
if(!SystemWrites.guard("UPDATE edi_outbound_request SET id=id WHERE id=? AND status='PENDING'", request.getValue("id")))
{
continue;
}
try
{
X12TransactionSet set = InboundApplier.asPartner(EdiRuntime.current().config().getServiceSessionSupplier(), agreement.getValueInteger("partnerId"), () ->
{
String table = request.getValueString("sourceTableName");
String primaryKey = QContext.getQInstance().getTable(table).getPrimaryKeyField();
var query = new QueryInput(table).withFilter(EdiRecords.equal(primaryKey, request.getValue("sourceRecordId")))
.withIncludeAssociations(true).withAssociationNamesToInclude(associations).withTransaction(QContext.getQBackendTransaction());
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var records = new QueryAction().execute(query).getRecords();
if(records.size() != 1 || (!"ACTIVE".equals(map.getValueString("status")) || !table.equals(map.getValueString("tableName"))))
{
throw new IllegalArgumentException("Source record or active map unavailable");
}
return (new OutboundMapper().build(records.getFirst(), map, rules, String.format("%04d", sets.size() + 1)));
});
if(EdiRuntime.current().validator().validate(set, guide, Boolean.TRUE.equals(document.getValueBoolean("containsPhi"))).stream().anyMatch(f -> "ERROR".equals(f.severity())))
{
reject(request, agreement, "VALIDATION");
continue;
}
accepted.add(request);
sets.add(set);
}
catch(Exception e)
{
reject(request, agreement, "MAPPING");
}
if(!"BATCH".equals(document.getValueString("batchMode")) && !sets.isEmpty())
{
envelope(agreement, document, map, accepted, sets, null);
accepted.clear();
sets.clear();
}
}
if(!sets.isEmpty())
{
envelope(agreement, document, map, accepted, sets, null);
}
return (requests.size());
})));
}
/*******************************************************************************
** Invalid documents are terminal requests and never enter the send queue.
*******************************************************************************/
private void reject(QRecord request, QRecord agreement, String type) throws Exception
{
SystemWrites.update("edi_outbound_request", new QRecord().withValue("id", request.getValue("id")).withValue("status", "FAILED").withValue("lastError", type));
EdiJournal.exception(null, null, null, agreement.getValueInteger("partnerId"), type, "Outbound request failed " + type.toLowerCase(java.util.Locale.ROOT));
}
/*******************************************************************************
** Allocate controls once, store exact bytes, then persist all tracking records.
*******************************************************************************/
public static QRecord envelope(QRecord agreement, QRecord document, QRecord map, List<QRecord> requests, List<X12TransactionSet> sets, Integer original) throws Exception
{
var runtime = EdiRuntime.current();
QRecord local = row("edi_identity", agreement.getValueInteger("localIdentityId"));
QRecord partner = row("edi_identity", agreement.getValueInteger("partnerIdentityId"));
LocalDateTime at = LocalDateTime.ofInstant(runtime.config().getClock().instant(), ZoneOffset.UTC);
String isaControl = String.format("%09d", allocate(agreement, "ISA"));
String gsControl = String.valueOf(allocate(agreement, "GS"));
String version = Objects.requireNonNullElse(agreement.getValueString("interchangeVersion"), "00401");
String component = value(agreement, "componentSeparator", ":");
String repetition = value(agreement, "repetitionSeparator", "^");
X12Segment isa = X12Segment.of("ISA", 1, "00", "", "00", "", local.getValueString("interchangeQualifier"), local.getValueString("interchangeId"),
partner.getValueString("interchangeQualifier"), partner.getValueString("interchangeId"), at.format(DateTimeFormatter.ofPattern("yyMMdd")), at.format(DateTimeFormatter.ofPattern("HHmm")),
version.compareTo("00501") >= 0 ? repetition : "U", version, isaControl, Boolean.TRUE.equals(agreement.getValueBoolean("requestTa1")) ? "1" : "0", agreement.getValueString("usageIndicator"), component);
X12Segment gs = X12Segment.of("GS", 2, document.getValueString("functionalIdCode"), local.getValueString("applicationCode"), partner.getValueString("applicationCode"), at.format(DateTimeFormatter.BASIC_ISO_DATE),
at.format(DateTimeFormatter.ofPattern("HHmm")), gsControl, "X", document.getValueString("version"));
var interchange = new X12Interchange(new X12Separators(value(agreement, "elementSeparator", "*").charAt(0), value(agreement, "segmentTerminator", "~").charAt(0), component.charAt(0), version.compareTo("00501") >= 0 ? repetition.charAt(0) : null),
isa, X12Segment.of("IEA", 0, "1", isaControl), List.of(new X12FunctionalGroup(gs, X12Segment.of("GE", 0, String.valueOf(sets.size()), gsControl), List.copyOf(sets), List.of())), List.of(), List.of());
String ending = switch(value(agreement, "lineEnding", "NONE"))
{
case "LF" -> "\n";
case "CRLF" -> "\r\n";
default -> "";
};
var payload = runtime.payloads().put(PayloadKind.OUTBOUND, new ByteArrayInputStream(runtime.writer().write(interchange, ending).getBytes(StandardCharsets.UTF_8)));
QRecord persisted = SystemWrites.insert("edi_interchange", new QRecord().withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId"))
.withValue("agreementId", agreement.getValue("id")).withValue("transportId", agreement.getValue("outboundTransportId")).withValue("standard", "X12")
.withValue("senderQualifier", isa.value(5)).withValue("senderId", isa.value(6)).withValue("receiverQualifier", isa.value(7)).withValue("receiverId", isa.value(8))
.withValue("controlNumber", isaControl).withValue("interchangeVersion", version).withValue("usageIndicator", isa.value(15)).withValue("interchangeDate", runtime.config().getClock().instant())
.withValue("groupCount", 1).withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes())
.withValue("status", "ENVELOPED").withValue("containsPhi", document.getValue("containsPhi")).withValue("originalInterchangeId", original));
boolean expectsAck = document.getValueString("ackExpectation") != null && !"NONE".equals(document.getValueString("ackExpectation"));
QRecord group = SystemWrites.insert("edi_functional_group", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("senderId", gs.value(2))
.withValue("functionalIdCode", gs.value(1)).withValue("controlNumber", gsControl).withValue("version", gs.value(8)).withValue("transactionCount", sets.size())
.withValue("ackStatus", expectsAck ? "PENDING" : "NONE"));
for(int i = 0; i < sets.size(); i++)
{
var set = sets.get(i);
String businessKey = map.getValueString("businessKeyPath") == null ? null : new com.kingsrook.qbits.edi.guide.RuleEvaluator(set.segments(), java.util.Map.of()).values(map.getValueString("businessKeyPath")).stream().findFirst().orElse(null);
SystemWrites.insert("edi_transaction", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("functionalGroupId", group.getValue("id"))
.withValue("direction", "OUTBOUND").withValue("partnerId", agreement.getValue("partnerId")).withValue("agreementDocumentId", document.getValue("id"))
.withValue("transactionSet", set.st().value(1)).withValue("version", gs.value(8)).withValue("controlNumber", set.st().value(2)).withValue("status", "QUEUED")
.withValue("guideId", document.getValue("guideId")).withValue("guideRevision", document.getValue("guideId") == null ? null : row("edi_guide", document.getValue("guideId")).getValue("revision"))
.withValue("targetTableName", i < requests.size() ? requests.get(i).getValue("sourceTableName") : null).withValue("targetRecordId", i < requests.size() ? requests.get(i).getValue("sourceRecordId") : null).withValue("mapId", map.getValue("id")).withValue("mapRevision", map.getValue("revision"))
.withValue("businessKeyType", map.getValue("businessKeyType")).withValue(Boolean.TRUE.equals(document.getValueBoolean("containsPhi")) ? "phiBusinessKey" : "businessKey", businessKey)
.withValue("containsPhi", document.getValue("containsPhi")).withValue("segmentCount", set.segments().size()));
if(i < requests.size())
{
SystemWrites.update("edi_outbound_request", new QRecord().withValue("id", requests.get(i).getValue("id")).withValue("status", "ENVELOPED"));
}
}
QRecord message = SystemWrites.insert("edi_outbound_message", new QRecord().withValue("interchangeId", persisted.getValue("id")).withValue("agreementId", agreement.getValue("id"))
.withValue("partnerId", agreement.getValue("partnerId")).withValue("transportId", agreement.getValue("outboundTransportId")).withValue("status", "QUEUED").withValue("attempts", 0)
.withValue("payloadRef", payload.value()).withValue("payloadSha256", payload.sha256()).withValue("sizeBytes", payload.sizeBytes()));
SystemWrites.update("edi_interchange", new QRecord().withValue("id", persisted.getValue("id")).withValue("outboundMessageId", message.getValue("id")));
EdiJournal.event(null, persisted.getValueInteger("id"), null, "ENVELOPED");
return (message);
}
/*******************************************************************************
** Allocate in the configured agreement or identity scope.
*******************************************************************************/
private static long allocate(QRecord agreement, String level) throws Exception
{
boolean local = "LOCAL_IDENTITY".equals(agreement.getValueString("controlNumberScope"));
return (EdiRuntime.current().controls().allocate(local ? null : agreement.getValueInteger("id"), local ? agreement.getValueInteger("localIdentityId") : null, level, 1, 999999999, true));
}
/*******************************************************************************
** Lookup required configuration without leaking submitted values.
*******************************************************************************/
public static QRecord row(String table, Serializable id) throws QException
{
if(id == null)
{
throw new QException("Required EDI record reference is absent");
}
return (EdiRecords.query(table, EdiRecords.equal("id", id)).getFirst());
}
/*******************************************************************************
** Default optional envelope settings.
*******************************************************************************/
private static String value(QRecord record, String field, String fallback)
{
return (Objects.requireNonNullElse(record.getValueString(field), fallback));
}
}
@@ -1,166 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.flow;
import java.time.Instant;
import java.util.List;
import java.util.Objects;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qbits.edi.support.SystemWrites;
import com.kingsrook.qbits.edi.transport.EdiSendResult;
import com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData;
import com.kingsrook.qqq.backend.core.alerts.AlertSeverity;
import com.kingsrook.qqq.backend.core.alerts.AlertSignal;
import com.kingsrook.qqq.backend.core.alerts.QAlerts;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Durable at-least-once sender. A database row lock fences concurrent senders;
** transport timeouts bound each attempt. Stable bytes/ids make retries detectable.
*******************************************************************************/
public class SendWorker
{
public static final List<Long> BACKOFF = List.of(60L, 300L, 900L, 3600L, 10800L, 21600L, 43200L, 86400L);
/*******************************************************************************
** Process only queued or due retry rows, including inbound-generated replies.
*******************************************************************************/
public Integer runOnce() throws QException
{
return (SystemWrites.call(() ->
{
int count = 0;
var due = new QQueryFilter().withBooleanOperator(QQueryFilter.BooleanOperator.OR).withCriteria("nextAttemptAt", QCriteriaOperator.IS_BLANK)
.withCriteria("nextAttemptAt", QCriteriaOperator.LESS_THAN_OR_EQUALS, now());
for(QRecord message : EdiRecords.query("edi_outbound_message", new QQueryFilter().withCriteria("status", QCriteriaOperator.IN, "QUEUED", "RETRY_WAIT").withSubFilter(due).withLimit(20)))
{
count += send(message);
}
return (count);
}));
}
/*******************************************************************************
** Compare selected status and attempts under lock before performing remote I/O.
*******************************************************************************/
private Integer send(QRecord selected) throws QException
{
return (SystemWrites.transaction(() ->
{
if(!SystemWrites.guard("UPDATE edi_outbound_message SET id=id WHERE id=? AND status=? AND COALESCE(attempts,0)=?", selected.getValue("id"), selected.getValue("status"), Objects.requireNonNullElse(selected.getValueInteger("attempts"), 0)))
{
return (0);
}
QRecord message = OutboundWorker.row("edi_outbound_message", selected.getValue("id"));
int attempts = Objects.requireNonNullElse(message.getValueInteger("attempts"), 0) + 1;
int maximum = 8;
EdiSendResult result;
try
{
QRecord transport = OutboundWorker.row("edi_transport", message.getValue("transportId"));
maximum = Objects.requireNonNullElse(transport.getValueInteger("maxAttempts"), 8);
if(!Boolean.TRUE.equals(transport.getValueBoolean("isActive")) || "INBOUND".equals(transport.getValueString("direction")))
{
result = new EdiSendResult("FAILED", null, "Outbound transport is disabled");
}
else
{
result = EdiTransportPluginsMetaData.current().get(transport.getValueString("type")).send(message.toEntity(EdiOutboundMessage.class),
new PayloadRef(message.getValueString("payloadRef"), message.getValueString("payloadSha256"), message.getValueLong("sizeBytes")), transport.toEntity(EdiTransport.class));
}
}
catch(Exception e)
{
result = new EdiSendResult("RETRY", null, "Transport attempt failed");
}
boolean sent = List.of("SENT", "DELIVERED").contains(result.status());
String status = sent ? result.status() : "FAILED".equals(result.status()) ? "FAILED" : attempts >= maximum ? "DEAD_LETTER" : "RETRY_WAIT";
QRecord update = new QRecord().withValue("id", message.getValue("id")).withValue("status", status).withValue("attempts", attempts)
.withValue("claimedAt", null).withValue("lastError", sent ? null : failureText(result))
.withValue("nextAttemptAt", "RETRY_WAIT".equals(status) ? now().plusSeconds(BACKOFF.get(Math.min(attempts - 1, BACKOFF.size() - 1))) : null);
if(sent)
{
update.withValue("transportMessageId", result.messageId()).withValue("sentAt", now());
if("DELIVERED".equals(status))
{
update.withValue("deliveredAt", now());
}
}
SystemWrites.update("edi_outbound_message", update);
Integer interchangeId = message.getValueInteger("interchangeId");
if(interchangeId != null && sent)
{
SystemWrites.update("edi_interchange", new QRecord().withValue("id", interchangeId).withValue("status", status));
for(QRecord group : EdiRecords.query("edi_functional_group", EdiRecords.equal("interchangeId", interchangeId)))
{
for(QRecord transaction : EdiRecords.query("edi_transaction", EdiRecords.equal("functionalGroupId", group.getValue("id"))))
{
if("QUEUED".equals(transaction.getValueString("status")))
{
SystemWrites.update("edi_transaction", new QRecord().withValue("id", transaction.getValue("id")).withValue("status", "SENT"));
}
if("PENDING".equals(group.getValueString("ackStatus")) && group.getValue("ackDueAt") == null)
{
QRecord document = OutboundWorker.row("edi_agreement_document", transaction.getValue("agreementDocumentId"));
SystemWrites.update("edi_functional_group", new QRecord().withValue("id", group.getValue("id"))
.withValue("ackDueAt", now().plusSeconds(60L * Objects.requireNonNullElse(document.getValueInteger("ackDueMinutes"), 1440))));
}
}
}
}
if(List.of("FAILED", "DEAD_LETTER").contains(status))
{
SystemWrites.insert("edi_exception", new QRecord().withValue("outboundMessageId", message.getValue("id")).withValue("interchangeId", interchangeId)
.withValue("partnerId", message.getValue("partnerId")).withValue("type", "TRANSPORT").withValue("status", "OPEN").withValue("occurrences", 1).withValue("summary", "Outbound delivery failed"));
QAlerts.raise(new AlertSignal("edi.send.failed").withSeverity(AlertSeverity.ERROR).withDedupKey("edi_outbound_message:" + message.getValue("id"))
.withTitle("EDI delivery failed").withSummary("Review the outbound message"));
}
EdiJournal.event(null, interchangeId, null, status);
return (1);
}));
}
/*******************************************************************************
** The recorded failure: the transport reason (value-free by contract, for
** example an HTTP status, an AS2 disposition or a missing variable name), or a
** generic text.
*******************************************************************************/
private static String failureText(EdiSendResult result)
{
String reason = result.reason();
return (reason == null || reason.isBlank() ? "Transport delivery failed" : reason.length() <= 255 ? reason : reason.substring(0, 255));
}
/*******************************************************************************
** Clock controlled by the host and deterministic tests.
*******************************************************************************/
private static Instant now()
{
return (EdiRuntime.current().config().getClock().instant());
}
}
@@ -1,31 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
/*******************************************************************************
** Positional situational trigger for 999 CTX; never includes business or element values.
*******************************************************************************/
public record AckContext(String segmentId, int segmentPosition, String loopId, Integer elementPosition, Integer componentPosition)
{
}
@@ -1,105 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadCrypto;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Checksum-based installation of original baseline guide resources.
*******************************************************************************/
public class BuiltInGuideLoader
{
public static final List<String> RESOURCES = List.of("850-004010", "850-005010", "997-004010", "997-005010", "999-005010X231A1", "855-004010", "855-005010", "810-004010", "810-005010", "846-004010", "846-005010", "860-004010", "860-005010", "856-004010", "856-005010", "940-004010", "940-005010", "945-004010", "945-005010", "214-004010", "214-005010", "204-004010", "204-005010");
/*******************************************************************************
** Read the documented JSON format in memory.
*******************************************************************************/
public Guide read(InputStream input) throws IOException
{
return (new ObjectMapper().readValue(input, Guide.class));
}
/*******************************************************************************
** Read one bundled baseline.
*******************************************************************************/
public Guide resource(String name) throws IOException
{
try(InputStream input = BuiltInGuideLoader.class.getResourceAsStream("/edi/guides/" + name + ".json"))
{
if(input == null)
{
throw new IOException("Unknown built-in guide");
}
return (read(input));
}
}
/*******************************************************************************
** Install changed guides after the host schema and service session are ready.
*******************************************************************************/
public List<Integer> load() throws Exception
{
List<Integer> ids = new ArrayList<>();
for(String resource : RESOURCES)
{
try(InputStream input = BuiltInGuideLoader.class.getResourceAsStream("/edi/guides/" + resource + ".json"))
{
ids.add(upsert(input.readAllBytes()));
}
}
return (ids);
}
/*******************************************************************************
** Retain previous revisions; identical content is a no-op.
*******************************************************************************/
public Integer upsert(byte[] json) throws Exception
{
Guide guide = read(new ByteArrayInputStream(json));
String checksum = PayloadCrypto.digest(json);
List<QRecord> revisions = EdiRecords.query("edi_guide", EdiRecords.equal("name", guide.name()));
int revision = 0;
QRecord latest = null;
for(QRecord row : revisions)
{
if("BUILT_IN".equals(row.getValueString("source")) && (latest == null || row.getValueInteger("revision") > latest.getValueInteger("revision")))
{
latest = row;
}
revision = Math.max(revision, row.getValueInteger("revision"));
}
if(latest != null && checksum.equals(latest.getValueString("compiledChecksum")))
{
return (latest.getValueInteger("id"));
}
return (new GuideRepository().save(guide, checksum, revision + 1));
}
}
@@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.util.List;
/*******************************************************************************
** In-house or imported implementation guide. Segment order is significant.
*******************************************************************************/
public record Guide(String name, String standard, String version, String transactionSet, String functionalIdCode, List<GuideSegment> segments, List<GuideRule> rules)
{
}
@@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.util.List;
/*******************************************************************************
** An element or composite component constraint.
*******************************************************************************/
public record GuideElement(Integer position, Integer componentPosition, String elementId, String dataType, Integer minLength, Integer maxLength, String usage, List<String> codes, String fixedValue, Integer repeatMax, String condition)
{
}
@@ -1,232 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qqq.backend.core.actions.QBackendTransaction;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Load and persist versioned normalized guide rows through standard QQQ actions.
*******************************************************************************/
public class GuideRepository
{
/*******************************************************************************
** Read a guide and all its ordered definitions.
*******************************************************************************/
public Guide load(Integer id) throws Exception
{
QRecord guide = rows("edi_guide", "id", id).getFirst();
List<QRecord> definitions = new ArrayList<>(rows("edi_guide_segment", "guideId", id));
definitions.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
List<GuideSegment> segments = new ArrayList<>();
List<QRecord> rules = rows("edi_guide_rule", "guideId", id);
for(QRecord definition : definitions)
{
Integer segmentId = definition.getValueInteger("id");
List<GuideElement> elements = new ArrayList<>();
for(QRecord element : rows("edi_guide_element", "guideSegmentId", segmentId))
{
List<String> codes = null;
if(element.getValueInteger("codeListId") != null)
{
codes = new ArrayList<>();
for(QRecord code : rows("edi_code_list_value", "codeListId", element.getValueInteger("codeListId")))
{
if(Boolean.TRUE.equals(code.getValueBoolean("isActive")))
{
codes.add(code.getValueString("code"));
}
}
}
elements.add(new GuideElement(element.getValueInteger("position"), element.getValueInteger("componentPosition"), element.getValueString("elementId"),
element.getValueString("dataType"), element.getValueInteger("minLength"), element.getValueInteger("maxLength"), element.getValueString("usage"), codes,
element.getValueString("fixedValue"), element.getValueInteger("repeatMax"), condition(rules, element.getValueInteger("situationalRuleId"))));
}
List<GuideRule> segmentRules = rules.stream().filter(r -> segmentId.equals(r.getValueInteger("guideSegmentId")) && !"condition".equals(r.getValueString("message"))).map(GuideRepository::rule).toList();
segments.add(new GuideSegment(definition.getValueString("segmentId"), definition.getValueString("loopId"), definition.getValueString("parentLoopId"),
definition.getValueInteger("loopMaxRepeat"), definition.getValueString("usage"), definition.getValueInteger("minUse"), definition.getValueInteger("maxUse"),
elements, segmentRules, condition(rules, definition.getValueInteger("situationalRuleId"))));
}
return (new Guide(guide.getValueString("name"), guide.getValueString("standard"), guide.getValueString("version"), guide.getValueString("transactionSet"),
guide.getValueString("functionalIdCode"), segments, rules.stream().filter(r -> r.getValueInteger("guideSegmentId") == null).map(GuideRepository::rule).toList()));
}
/*******************************************************************************
** Insert a complete new revision atomically, retaining every previous revision.
*******************************************************************************/
public Integer save(Guide guide, String checksum, int revision) throws Exception
{
try(QBackendTransaction transaction = QBackendTransaction.openFor(new InsertInput("edi_guide")))
{
try
{
QRecord row = insert("edi_guide", new QRecord().withValue("name", guide.name()).withValue("standard", guide.standard()).withValue("version", guide.version())
.withValue("transactionSet", guide.transactionSet()).withValue("functionalIdCode", guide.functionalIdCode()).withValue("revision", revision)
.withValue("status", "ACTIVE").withValue("source", "BUILT_IN").withValue("compiledChecksum", checksum), transaction);
Integer id = row.getValueInteger("id");
int sequence = 0;
for(GuideSegment segment : guide.segments())
{
QRecord segmentRow = insert("edi_guide_segment", new QRecord().withValue("guideId", id).withValue("sequence", ++sequence)
.withValue("segmentId", segment.segmentId()).withValue("name", segment.segmentId()).withValue("position", String.format("%04d", sequence * 10))
.withValue("loopId", segment.loopId()).withValue("parentLoopId", segment.parentLoopId()).withValue("loopMaxRepeat", segment.loopMaxRepeat())
.withValue("usage", segment.usage()).withValue("minUse", segment.minUse()).withValue("maxUse", segment.maxUse()), transaction);
Integer segmentId = segmentRow.getValueInteger("id");
for(GuideElement element : list(segment.elements()))
{
Integer codeListId = null;
if(element.codes() != null)
{
codeListId = insert("edi_code_list", new QRecord().withValue("name", guide.name() + "/" + revision + "/" + sequence + "/" + element.position())
.withValue("elementId", element.elementId()).withValue("source", "BUILT_IN"), transaction).getValueInteger("id");
for(String code : element.codes())
{
insert("edi_code_list_value", new QRecord().withValue("codeListId", codeListId).withValue("code", code).withValue("isActive", true), transaction);
}
}
insert("edi_guide_element", new QRecord().withValue("guideSegmentId", segmentId).withValue("position", element.position())
.withValue("componentPosition", element.componentPosition()).withValue("elementId", element.elementId()).withValue("dataType", element.dataType())
.withValue("minLength", element.minLength()).withValue("maxLength", element.maxLength()).withValue("usage", element.usage())
.withValue("codeListId", codeListId).withValue("fixedValue", element.fixedValue()).withValue("repeatMax", element.repeatMax())
.withValue("situationalRuleId", saveCondition(id, segmentId, element.condition(), transaction)), transaction);
}
if(segment.condition() != null)
{
new UpdateAction().execute(new UpdateInput("edi_guide_segment").withTransaction(transaction).withRecord(new QRecord().withValue("id", segmentId)
.withValue("situationalRuleId", saveCondition(id, segmentId, segment.condition(), transaction))));
}
for(GuideRule rule : list(segment.rules()))
{
saveRule(id, segmentId, rule, transaction);
}
}
for(GuideRule rule : list(guide.rules()))
{
saveRule(id, null, rule, transaction);
}
for(QRecord previous : EdiRecords.query("edi_guide", EdiRecords.equal("name", guide.name())))
{
if("BUILT_IN".equals(previous.getValueString("source")) && "ACTIVE".equals(previous.getValueString("status")) && !id.equals(previous.getValueInteger("id")))
{
QRecord updated = new UpdateAction().execute(new UpdateInput("edi_guide").withTransaction(transaction)
.withRecord(new QRecord().withValue("id", previous.getValueInteger("id")).withValue("status", "RETIRED"))).getRecords().getFirst();
if(updated.getErrors() != null && !updated.getErrors().isEmpty())
{
throw new QException("Previous guide revision could not be retired");
}
}
}
transaction.commit();
return (id);
}
catch(Exception e)
{
transaction.rollback();
throw e;
}
}
}
/*******************************************************************************
** Store a condition as a referenced guide rule.
*******************************************************************************/
private static Integer saveCondition(Integer guideId, Integer segmentId, String expression, QBackendTransaction transaction) throws Exception
{
if(expression == null)
{
return (null);
}
return (saveRule(guideId, segmentId, new GuideRule("SITUATIONAL", List.of(), expression, 2, "ERROR", "condition", "8", "2", "8", "2"), transaction));
}
/*******************************************************************************
** Store syntax and situational rules without losing ack codes.
*******************************************************************************/
private static Integer saveRule(Integer guideId, Integer segmentId, GuideRule rule, QBackendTransaction transaction) throws Exception
{
return (insert("edi_guide_rule", new QRecord().withValue("guideId", guideId).withValue("guideSegmentId", segmentId).withValue("type", rule.type())
.withValue("positions", list(rule.positions()).stream().map(Object::toString).collect(Collectors.joining(","))).withValue("expression", rule.expression())
.withValue("snipLevel", rule.snipLevel()).withValue("severity", rule.severity()).withValue("message", rule.message())
.withValue("ak3Code", rule.ak3Code()).withValue("ak4Code", rule.ak4Code()).withValue("ik3Code", rule.ik3Code()).withValue("ik4Code", rule.ik4Code()), transaction).getValueInteger("id"));
}
/*******************************************************************************
** Reconstruct a syntax or situational rule.
*******************************************************************************/
private static GuideRule rule(QRecord row)
{
String positions = row.getValueString("positions");
return (new GuideRule(row.getValueString("type"), positions == null || positions.isBlank() ? List.of() : Arrays.stream(positions.split(",")).map(Integer::valueOf).toList(),
row.getValueString("expression"), row.getValueInteger("snipLevel"), row.getValueString("severity"), row.getValueString("message"), row.getValueString("ak3Code"),
row.getValueString("ak4Code"), row.getValueString("ik3Code"), row.getValueString("ik4Code")));
}
/*******************************************************************************
** Resolve a condition by its explicit row id.
*******************************************************************************/
private static String condition(List<QRecord> rules, Integer id)
{
return (rules.stream().filter(r -> Objects.equals(r.getValueInteger("id"), id)).map(r -> r.getValueString("expression")).findFirst().orElse(null));
}
/*******************************************************************************
** Query normalized child records.
*******************************************************************************/
private static List<QRecord> rows(String table, String field, Integer id) throws Exception
{
return (EdiRecords.query(table, EdiRecords.equal(field, id)));
}
/*******************************************************************************
** Insert within the owning guide transaction, checking validation errors.
*******************************************************************************/
private static QRecord insert(String table, QRecord row, QBackendTransaction transaction) throws Exception
{
QRecord result = new InsertAction().execute(new InsertInput(table).withTransaction(transaction).withRecord(row)).getRecords().getFirst();
if(result.getErrors() != null && !result.getErrors().isEmpty())
{
throw new QException("Guide record validation failed for " + table);
}
return (result);
}
/*******************************************************************************
** Treat omitted optional lists as empty.
*******************************************************************************/
private static <T> List<T> list(List<T> list)
{
return (list == null ? List.of() : list);
}
}
@@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.util.List;
/*******************************************************************************
** A syntax note or small-language expression; no executable host code.
*******************************************************************************/
public record GuideRule(String type, List<Integer> positions, String expression, Integer snipLevel, String severity, String message, String ak3Code, String ak4Code, String ik3Code, String ik4Code)
{
}
@@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.util.List;
/*******************************************************************************
** Ordered segment definition; the first segment of a loop starts each occurrence.
*******************************************************************************/
public record GuideSegment(String segmentId, String loopId, String parentLoopId, Integer loopMaxRepeat, String usage, Integer minUse, Integer maxUse, List<GuideElement> elements, List<GuideRule> rules, String condition)
{
}
@@ -1,412 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
/*******************************************************************************
** SNIP 1-2 structural and semantic validation over StAEDI-tokenized segments.
*******************************************************************************/
public class GuideValidator
{
/*******************************************************************************
** Validate one transaction; diagnostic text never contains submitted values.
*******************************************************************************/
public List<ValidationFinding> validate(X12TransactionSet transaction, Guide guide, boolean containsPhi)
{
State state = new State(transaction.segments(), containsPhi);
List<Node> roots = new ArrayList<>();
Map<String, Node> nodes = new LinkedHashMap<>();
for(GuideSegment segment : guide.segments())
{
List<Node> destination = roots;
if(segment.loopId() != null && !segment.loopId().isBlank())
{
Node loop = nodes.get(segment.loopId());
if(loop == null)
{
loop = new Node(segment.loopId(), null);
nodes.put(segment.loopId(), loop);
Node parent = nodes.get(segment.parentLoopId());
(parent == null ? roots : parent.children).add(loop);
}
destination = loop.children;
}
destination.add(new Node(null, segment));
}
consume(roots, state, false);
while(state.index < state.segments.size())
{
X12Segment segment = state.segments.get(state.index++);
boolean known = guide.segments().stream().anyMatch(s -> s.segmentId().equals(segment.id()));
state.add(segment, null, null, known ? "7" : "2", null, known ? "Segment out of order" : "Unexpected segment", null);
}
RuleEvaluator evaluator = new RuleEvaluator(state.segments, state.loops);
for(GuideSegment definition : guide.segments())
{
if(definition.condition() != null)
{
List<List<X12Segment>> scopes = definition.loopId() == null ? List.of(state.segments) : state.loops.getOrDefault(definition.loopId(), List.of(state.segments));
for(List<X12Segment> scope : scopes)
{
RuleEvaluator scoped = new RuleEvaluator(scope, state.loops);
if(safeTest(scoped, definition.condition()) && state.bindings.stream().noneMatch(b -> b.definition() == definition && scope.contains(b.segment())))
{
state.add(X12Segment.of(definition.segmentId(), scope.isEmpty() ? 1 : scope.getLast().position() + 1), definition.loopId(), null, "3", null, "Situational segment required", null);
int last = state.findings.size() - 1;
state.findings.set(last, state.findings.get(last).withContext(scoped.context(definition.condition())));
}
}
}
}
for(GuideRule rule : list(guide.rules()))
{
rule(rule, transaction.st(), null, evaluator, state);
}
for(Binding binding : state.bindings)
{
GuideSegment definition = binding.definition();
X12Segment segment = binding.segment();
elements(segment, definition, evaluator, state);
for(GuideRule rule : list(definition.rules()))
{
rule(rule, segment, definition.loopId(), evaluator, state);
}
}
return (List.copyOf(state.findings));
}
/*******************************************************************************
** Resolve guide-defined loop occurrences for the mapper using the validator grammar.
*******************************************************************************/
public Map<String, List<List<X12Segment>>> loops(X12TransactionSet transaction, Guide guide)
{
State state = new State(transaction.segments(), true);
List<Node> roots = new ArrayList<>();
Map<String, Node> nodes = new LinkedHashMap<>();
for(GuideSegment segment : guide.segments())
{
List<Node> destination = roots;
if(segment.loopId() != null && !segment.loopId().isBlank())
{
Node loop = nodes.get(segment.loopId());
if(loop == null)
{
loop = new Node(segment.loopId(), null);
nodes.put(segment.loopId(), loop);
Node parent = nodes.get(segment.parentLoopId());
(parent == null ? roots : parent.children).add(loop);
}
destination = loop.children;
}
destination.add(new Node(null, segment));
}
consume(roots, state, false);
return (state.loops);
}
/*******************************************************************************
** Consume ordered segments and nested loop occurrences.
*******************************************************************************/
private static void consume(List<Node> nodes, State state, boolean loopOccurrence)
{
for(Node node : nodes)
{
if(node.definition == null)
{
GuideSegment head = node.children.getFirst().definition;
int count = 0;
while(state.matches(head.segmentId()))
{
int start = state.index;
consume(node.children, state, true);
if(start == state.index)
{
break;
}
state.loops.computeIfAbsent(node.loopId, key -> new ArrayList<>()).add(state.segments.subList(start, state.index));
if(++count > number(head.loopMaxRepeat(), 1))
{
state.add(state.segments.get(start), node.loopId, null, "4", null, "Loop repeat limit exceeded", null);
}
}
if(count == 0 && "REQUIRED".equals(head.usage()))
{
state.add(X12Segment.of(head.segmentId(), state.position()), node.loopId, null, "3", null, "Required loop missing", null);
}
}
else
{
GuideSegment definition = node.definition;
int count = 0;
int maximum = number(definition.maxUse(), 1);
while(state.matches(definition.segmentId()) && (count == 0 || !loopOccurrence || node != nodes.getFirst()))
{
X12Segment segment = state.segments.get(state.index++);
state.bindings.add(new Binding(segment, definition));
count++;
if("NOT_USED".equals(definition.usage()))
{
state.add(segment, definition.loopId(), null, "6", null, "Segment is not used", null);
}
if(count > maximum)
{
state.add(segment, definition.loopId(), null, "5", null, "Segment use limit exceeded", null);
}
}
int minimum = Math.max(number(definition.minUse(), 0), "REQUIRED".equals(definition.usage()) ? 1 : 0);
if(count < minimum)
{
state.add(X12Segment.of(definition.segmentId(), state.position()), definition.loopId(), null, "3", null, "Required segment missing", null);
}
}
}
}
/*******************************************************************************
** Apply usage, lengths, data types, code lists, fixed values and repetition bounds.
*******************************************************************************/
private static void elements(X12Segment segment, GuideSegment definition, RuleEvaluator evaluator, State state)
{
for(GuideElement element : list(definition.elements()))
{
int repetitions = element.position() <= segment.elements().size() ? segment.elements().get(element.position() - 1).size() : 1;
if(repetitions > number(element.repeatMax(), 1))
{
state.add(segment, definition.loopId(), element, "8", "12", "Element repetition limit exceeded", null);
}
for(int r = 1; r <= repetitions; r++)
{
String value = segment.value(element.position(), r, number(element.componentPosition(), 1));
boolean required = "REQUIRED".equals(element.usage()) || element.condition() != null && safeTest(evaluator, element.condition());
if(value.isEmpty())
{
if(required)
{
state.add(segment, definition.loopId(), element, "8", "1", "Required element missing", null);
if(element.condition() != null)
{
int last = state.findings.size() - 1;
state.findings.set(last, state.findings.get(last).withContext(evaluator.context(element.condition())));
}
}
continue;
}
if("NOT_USED".equals(element.usage()))
{
state.add(segment, definition.loopId(), element, "8", "10", "Element is not used", value);
}
if(value.length() < number(element.minLength(), 0))
{
state.add(segment, definition.loopId(), element, "8", "4", "Element too short", value);
}
if(value.length() > number(element.maxLength(), Integer.MAX_VALUE))
{
state.add(segment, definition.loopId(), element, "8", "5", "Element too long", value);
}
if(!validType(element.dataType(), value))
{
String code = "DT".equals(element.dataType()) ? "8" : "TM".equals(element.dataType()) ? "9" : "6";
state.add(segment, definition.loopId(), element, "8", code, "Invalid element data type", value);
}
if(element.fixedValue() != null && !element.fixedValue().equals(value)
|| "ID".equals(element.dataType()) && element.codes() != null && !element.codes().contains(value))
{
state.add(segment, definition.loopId(), element, "8", "7", "Invalid code or fixed value", value);
}
}
}
}
/*******************************************************************************
** Validate X12 lexical data types without coercing bad values.
*******************************************************************************/
public static boolean validType(String type, String value)
{
if(type == null || type.equals("AN") || type.equals("ID") || type.equals("B"))
{
return (value.chars().noneMatch(Character::isISOControl));
}
if(type.matches("N[0-9]"))
{
return (value.matches("-?[0-9]+"));
}
if(type.equals("R"))
{
return (value.matches("-?(?:[0-9]+(?:\\.[0-9]*)?|\\.[0-9]+)"));
}
if(type.equals("DT"))
{
return (RuleEvaluator.validDate(value));
}
if(type.equals("TM"))
{
return (value.matches("(?:[01][0-9]|2[0-3])[0-5][0-9](?:[0-5][0-9](?:[0-9]{1,2})?)?"));
}
throw new IllegalArgumentException("Unknown guide data type");
}
/*******************************************************************************
** Evaluate syntax notes and explicit expressions; PHI rules use a safe message.
*******************************************************************************/
private static void rule(GuideRule rule, X12Segment segment, String loopId, RuleEvaluator evaluator, State state)
{
List<Integer> positions = list(rule.positions());
long present = positions.stream().filter(p -> !segment.value(p).isEmpty()).count();
boolean first = !positions.isEmpty() && !segment.value(positions.getFirst()).isEmpty();
boolean passed = switch(rule.type())
{
case "PAIRED", "P" -> present == 0 || present == positions.size();
case "REQUIRED", "R" -> present > 0;
case "EXCLUSION", "E" -> present <= 1;
case "CONDITIONAL", "C" -> !first || present == positions.size();
case "LIST_CONDITIONAL", "L" -> !first || present > 1;
case "SITUATIONAL" -> safeTest(evaluator, rule.expression());
default -> throw new IllegalArgumentException("Unknown syntax note");
};
if(!passed)
{
state.findings.add(new ValidationFinding(segment.position(), segment.id(), loopId, positions.isEmpty() ? null : positions.getFirst(), null,
number(rule.snipLevel(), 2), fallback(rule.ak3Code(), "8"), fallback(rule.ak4Code(), "2"), fallback(rule.ik3Code(), "8"), fallback(rule.ik4Code(), "2"),
fallback(rule.severity(), "ERROR"), state.phi ? "Guide rule failed" : fallback(rule.message(), "Guide rule failed"), null)
.withContext("SITUATIONAL".equals(rule.type()) ? evaluator.context(rule.expression()) : null));
}
}
/*******************************************************************************
** Bad data cannot leak through numeric or regular-expression exception messages.
*******************************************************************************/
private static boolean safeTest(RuleEvaluator evaluator, String expression)
{
try
{
return (evaluator.test(expression));
}
catch(IllegalArgumentException e)
{
return (false);
}
}
/*******************************************************************************
** Use a default for omitted optional numeric constraints.
*******************************************************************************/
private static int number(Integer value, int fallback)
{
return (value == null ? fallback : value);
}
/*******************************************************************************
** Use a default for omitted optional text.
*******************************************************************************/
private static String fallback(String value, String fallback)
{
return (value == null ? fallback : value);
}
/*******************************************************************************
** Normalize omitted JSON lists.
*******************************************************************************/
private static <T> List<T> list(List<T> value)
{
return (value == null ? List.of() : value);
}
/*******************************************************************************
** A guide tree node.
*******************************************************************************/
private static class Node
{
private final String loopId;
private final GuideSegment definition;
private final List<Node> children = new ArrayList<>();
/*******************************************************************************
** Create a loop or segment node.
*******************************************************************************/
private Node(String loopId, GuideSegment definition)
{
this.loopId = loopId;
this.definition = definition;
}
}
/*******************************************************************************
** Bind a parsed segment to its guide definition.
*******************************************************************************/
private record Binding(X12Segment segment, GuideSegment definition)
{
}
/*******************************************************************************
** Per-call state; validator instances are safe to share.
*******************************************************************************/
private static class State
{
private final List<X12Segment> segments;
private final boolean phi;
private final List<ValidationFinding> findings = new ArrayList<>();
private final List<Binding> bindings = new ArrayList<>();
private final Map<String, List<List<X12Segment>>> loops = new LinkedHashMap<>();
private int index;
/*******************************************************************************
** Initialize a transaction validation.
*******************************************************************************/
private State(List<X12Segment> segments, boolean phi)
{
this.segments = segments;
this.phi = phi;
}
/*******************************************************************************
** Check the next segment.
*******************************************************************************/
private boolean matches(String id)
{
return (index < segments.size() && segments.get(index).id().equals(id));
}
/*******************************************************************************
** Locate a missing segment relative to the buffered transaction.
*******************************************************************************/
private int position()
{
return (index < segments.size() ? segments.get(index).position() : segments.size() + 1);
}
/*******************************************************************************
** Add a diagnostic with a bounded, optionally suppressed excerpt.
*******************************************************************************/
private void add(X12Segment segment, String loopId, GuideElement element, String segmentCode, String elementCode, String message, String value)
{
findings.add(new ValidationFinding(segment.position(), segment.id(), loopId, element == null ? null : element.position(), element == null ? null : element.componentPosition(),
2, segmentCode, elementCode, segmentCode, elementCode, "ERROR", message, phi || value == null ? null : value.substring(0, Math.min(64, value.length()))));
}
}
}
@@ -1,363 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
import java.math.BigDecimal;
import java.time.DateTimeException;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import com.kingsrook.qbits.edi.x12.X12Segment;
/*******************************************************************************
** Parser and evaluator for the closed EDI rule language. No scripting or reflection.
*******************************************************************************/
public class RuleEvaluator
{
private final List<X12Segment> segments;
private final Map<String, List<List<X12Segment>>> loops;
/*******************************************************************************
** Bind document segments and guide-derived loop occurrences.
*******************************************************************************/
public RuleEvaluator(List<X12Segment> segments, Map<String, List<List<X12Segment>>> loops)
{
this.segments = segments;
this.loops = loops;
}
/*******************************************************************************
** Require the expression to return a boolean.
*******************************************************************************/
public boolean test(String expression)
{
Object result = evaluate(expression.trim());
if(!(result instanceof Boolean))
{
throw new IllegalArgumentException("Rule must return a boolean");
}
return ((Boolean) result);
}
/*******************************************************************************
** Evaluate one expression recursively with explicit functions only.
*******************************************************************************/
public Object evaluate(String expression)
{
String text = expression.trim();
if(text.startsWith("'") && text.endsWith("'"))
{
return (text.substring(1, text.length() - 1).replace("\'", "'"));
}
if(text.matches("-?[0-9]+(?:\\.[0-9]+)?"))
{
return (new BigDecimal(text));
}
int open = text.indexOf('(');
if(open < 0)
{
return (values(text));
}
if(!text.endsWith(")"))
{
throw new IllegalArgumentException("Unclosed rule function");
}
String function = text.substring(0, open).trim();
List<String> args = arguments(text.substring(open + 1, text.length() - 1));
int arity = switch(function)
{
case "present", "absent", "count", "sum", "date", "not" -> 1;
case "eq", "matches", "when" -> 2;
case "in" -> -2;
case "and", "or" -> -1;
default -> throw new IllegalArgumentException("Unknown rule function");
};
if(arity > 0 && args.size() != arity || arity < 0 && args.size() < -arity)
{
throw new IllegalArgumentException("Invalid rule argument count");
}
return (switch(function)
{
case "present" -> !asValues(evaluate(arg(args, 0))).isEmpty();
case "absent" -> asValues(evaluate(arg(args, 0))).isEmpty();
case "count" -> BigDecimal.valueOf(asValues(evaluate(arg(args, 0))).size());
case "sum" -> asValues(evaluate(arg(args, 0))).stream().map(BigDecimal::new).reduce(BigDecimal.ZERO, BigDecimal::add);
case "eq" -> equal(evaluate(arg(args, 0)), evaluate(arg(args, 1)));
case "in" -> args.subList(1, args.size()).stream().anyMatch(a -> equal(evaluate(arg(args, 0)), evaluate(a)));
case "matches" -> asValues(evaluate(arg(args, 0))).stream().allMatch(v -> v.matches(scalar(evaluate(arg(args, 1)))));
case "date" -> validDate(scalar(evaluate(arg(args, 0))));
case "and" -> args.stream().allMatch(this::test);
case "or" -> args.stream().anyMatch(this::test);
case "not" -> !test(arg(args, 0));
case "when" -> !test(arg(args, 0)) || test(arg(args, 1));
default -> throw new IllegalArgumentException("Unknown rule function");
});
}
/*******************************************************************************
** Extract a required argument.
*******************************************************************************/
private static String arg(List<String> args, int index)
{
if(index >= args.size())
{
throw new IllegalArgumentException("Missing rule argument");
}
return (args.get(index));
}
/*******************************************************************************
** Split function arguments outside nested calls, predicates and quoted strings.
*******************************************************************************/
private static List<String> arguments(String text)
{
List<String> result = new ArrayList<>();
boolean quoted = false;
int depth = 0;
int start = 0;
for(int i = 0; i < text.length(); i++)
{
char c = text.charAt(i);
if(c == '\'' && (i == 0 || text.charAt(i - 1) != '\\'))
{
quoted = !quoted;
}
if(!quoted)
{
if(c == '(' || c == '[')
{
depth++;
}
if(c == ')' || c == ']')
{
depth--;
}
if(c == ',' && depth == 0)
{
result.add(text.substring(start, i).trim());
start = i + 1;
}
}
}
if(quoted || depth != 0)
{
throw new IllegalArgumentException("Invalid rule syntax");
}
result.add(text.substring(start).trim());
return (result);
}
/*******************************************************************************
** Resolve loop paths, segment predicates, one-based indexes and components.
*******************************************************************************/
public List<String> values(String path)
{
List<X12Segment> scope = segments;
String[] steps = path.split("/");
for(int i = 0; i < steps.length; i++)
{
String step = steps[i];
Matcher selector = Pattern.compile("([^\\[]+)(?:\\[(.*)])?").matcher(step);
if(!selector.matches())
{
throw new IllegalArgumentException("Invalid EDI path");
}
String name = selector.group(1);
String qualifier = selector.group(2);
Matcher element = Pattern.compile("([A-Z][A-Z0-9]{1,2}?)([0-9]{2})(?:-([0-9]+))?").matcher(name);
if(element.matches())
{
List<X12Segment> selected = select(scope, element.group(1), qualifier);
int position = Integer.parseInt(element.group(2));
int component = element.group(3) == null ? 1 : Integer.parseInt(element.group(3));
List<String> result = new ArrayList<>();
for(X12Segment segment : selected)
{
if(position <= segment.elements().size())
{
for(int r = 1; r <= segment.elements().get(position - 1).size(); r++)
{
String value = segment.value(position, r, component);
if(!value.isEmpty())
{
result.add(value);
}
}
}
}
return (result);
}
List<List<X12Segment>> occurrences = loops.get(name);
if(occurrences == null)
{
List<List<X12Segment>> byAnchor = loops.values().stream().flatMap(List::stream)
.filter(o -> !o.isEmpty() && o.getFirst().id().equals(name)).distinct()
.sorted(java.util.Comparator.comparingInt(o -> o.getFirst().position())).toList();
if(!byAnchor.isEmpty())
{
occurrences = byAnchor;
}
}
if(occurrences != null)
{
List<X12Segment> priorScope = scope;
occurrences = occurrences.stream().filter(o -> !o.isEmpty() && priorScope.contains(o.getFirst())).toList();
List<X12Segment> heads = select(occurrences.stream().map(List::getFirst).toList(), null, qualifier);
scope = occurrences.stream().filter(o -> heads.contains(o.getFirst())).flatMap(List::stream).toList();
if(i == steps.length - 1)
{
return (heads.stream().map(X12Segment::id).toList());
}
}
else
{
scope = select(scope, name, qualifier);
}
}
return (scope.stream().map(X12Segment::id).toList());
}
/*******************************************************************************
** Apply segment selection without evaluating arbitrary expressions.
*******************************************************************************/
private static List<X12Segment> select(List<X12Segment> scope, String name, String qualifier)
{
List<X12Segment> result = scope.stream().filter(s -> name == null || name.equals(s.id())).toList();
if(qualifier == null || qualifier.equals("*"))
{
return (result);
}
if(qualifier.matches("[0-9]+"))
{
int index = Integer.parseInt(qualifier) - 1;
return (index >= 0 && index < result.size() ? List.of(result.get(index)) : List.of());
}
Matcher predicate = Pattern.compile("[A-Z][A-Z0-9]{1,2}?([0-9]{2})(?:-([0-9]+))?=(.*)").matcher(qualifier);
if(!predicate.matches())
{
throw new IllegalArgumentException("Invalid path predicate");
}
int position = Integer.parseInt(predicate.group(1));
int component = predicate.group(2) == null ? 1 : Integer.parseInt(predicate.group(2));
String expected = predicate.group(3).replace("'", "");
return (result.stream().filter(s -> s.value(position, 1, component).equals(expected)).toList());
}
/*******************************************************************************
** Convert values to a scalar for scalar-only functions.
*******************************************************************************/
private static String scalar(Object value)
{
List<String> values = asValues(value);
return (values.isEmpty() ? "" : values.getFirst());
}
/*******************************************************************************
** Normalize scalar and path results.
*******************************************************************************/
private static List<String> asValues(Object value)
{
if(value instanceof List<?> list)
{
return (list.stream().map(Object::toString).toList());
}
return (List.of(value.toString()));
}
/*******************************************************************************
** Compare numerically when either argument is numeric, otherwise by value.
*******************************************************************************/
private static boolean equal(Object left, Object right)
{
if(left instanceof BigDecimal || right instanceof BigDecimal)
{
try
{
return (new BigDecimal(scalar(left)).compareTo(new BigDecimal(scalar(right))) == 0);
}
catch(NumberFormatException e)
{
return (false);
}
}
return (asValues(left).stream().anyMatch(asValues(right)::contains));
}
/*******************************************************************************
** Validate six- or eight-digit calendar dates; YY uses the 1950-2049 window.
*******************************************************************************/
public static boolean validDate(String value)
{
if(!value.matches("[0-9]{6}|[0-9]{8}"))
{
return (false);
}
if(value.length() == 6)
{
value = (Integer.parseInt(value.substring(0, 2)) >= 50 ? "19" : "20") + value;
}
try
{
LocalDate.parse(value, DateTimeFormatter.BASIC_ISO_DATE);
return (true);
}
catch(DateTimeException e)
{
return (false);
}
}
/*******************************************************************************
** Locate the first present trigger referenced by a situational condition; values never leave the evaluator.
*******************************************************************************/
public AckContext context(String expression)
{
String condition = expression.trim();
if(condition.startsWith("when("))
{
condition = arguments(condition.substring(5, condition.length() - 1)).getFirst();
}
// Remove literals before recognizing paths so quoted business text cannot become context.
condition = condition.replaceAll("'[^']*'", "");
Matcher matcher = Pattern.compile("([A-Z][A-Z0-9]{1,2}?)([0-9]{2})(?:-([0-9]+))?").matcher(condition);
while(matcher.find())
{
String id = matcher.group(1);
int position = Integer.parseInt(matcher.group(2));
Integer component = matcher.group(3) == null ? null : Integer.valueOf(matcher.group(3));
for(X12Segment segment : segments)
{
if(segment.id().equals(id) && !segment.value(position, 1, component == null ? 1 : component).isEmpty())
{
String loop = loops.entrySet().stream().filter(e -> e.getValue().stream().anyMatch(o -> o.contains(segment))).map(Map.Entry::getKey).findFirst().orElse(null);
return (new AckContext(id, segment.position(), loop, position, component));
}
}
}
return (null);
}
}
@@ -1,47 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.guide;
/*******************************************************************************
** Structured validation result; PHI values must never appear in text or excerpts.
*******************************************************************************/
public record ValidationFinding(int segmentPosition, String segmentId, String loopId, Integer elementPosition, Integer componentPosition, int snipLevel, String ak3Code, String ak4Code, String ik3Code, String ik4Code, String severity, String message, String valueExcerpt, AckContext context)
{
/*******************************************************************************
** Construct a finding without situational trigger context.
*******************************************************************************/
public ValidationFinding(int segmentPosition, String segmentId, String loopId, Integer elementPosition, Integer componentPosition, int snipLevel,
String ak3Code, String ak4Code, String ik3Code, String ik4Code, String severity, String message, String valueExcerpt)
{
this(segmentPosition, segmentId, loopId, elementPosition, componentPosition, snipLevel, ak3Code, ak4Code, ik3Code, ik4Code, severity, message, valueExcerpt, null);
}
/*******************************************************************************
** Attach only positional acknowledgement context.
*******************************************************************************/
public ValidationFinding withContext(AckContext context)
{
return (new ValidationFinding(segmentPosition, segmentId, loopId, elementPosition, componentPosition, snipLevel, ak3Code, ak4Code, ik3Code, ik4Code, severity, message, valueExcerpt, context));
}
}
@@ -1,166 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.map;
import java.util.List;
import java.util.Map;
import java.util.function.Supplier;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.InsertAction;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.actions.tables.UpdateAction;
import com.kingsrook.qqq.backend.core.context.QContext;
import com.kingsrook.qqq.backend.core.model.actions.tables.insert.InsertInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
import com.kingsrook.qqq.backend.core.model.actions.tables.update.UpdateInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.session.QSession;
/*******************************************************************************
** Permission-enforced target writes, including association writes and upsert reads.
*******************************************************************************/
public class InboundApplier
{
/*******************************************************************************
** Run mapping and applying with the supplied least-privilege service identity.
*******************************************************************************/
public static <T> T asService(Supplier<QSession> supplier, com.kingsrook.qbits.edi.support.SystemWrites.Work<T> work) throws Exception
{
if(supplier == null)
{
throw new IllegalStateException("no service session configured");
}
QSession session = supplier.get();
if(session == null)
{
throw new IllegalStateException("no service session configured");
}
QSession previous = QContext.getQSession();
QContext.setQSession(session);
try
{
return (work.run());
}
finally
{
QContext.setQSession(previous);
}
}
/*******************************************************************************
** Add this transaction's partner key to a copy of the configured session.
*******************************************************************************/
public static <T> T asPartner(Supplier<QSession> supplier, Integer partner, com.kingsrook.qbits.edi.support.SystemWrites.Work<T> work) throws Exception
{
return (asService(supplier, () ->
{
QSession prior = QContext.getQSession();
QSession session = prior.clone();
Map<String, List<java.io.Serializable>> keys = new java.util.HashMap<>();
if(prior.getSecurityKeyValues() != null)
{
prior.getSecurityKeyValues().forEach((key, values) -> keys.put(key, new java.util.ArrayList<>(values)));
}
session.setSecurityKeyValues(keys);
if(partner != null)
{
session.withSecurityKeyValue("edi_partner", partner);
}
QContext.setQSession(session);
try
{
return (work.run());
}
finally
{
QContext.setQSession(prior);
}
}));
}
/*******************************************************************************
** The caller owns the per-transaction-set database transaction.
*******************************************************************************/
public QRecord apply(QRecord map, QRecord record) throws Exception
{
String table = map.getValueString("tableName");
var targetTable = QContext.getQInstance().getTable(table);
String backend = com.kingsrook.qbits.edi.EdiRuntime.current().config().getBackendName();
if(targetTable == null || !backend.equals(targetTable.getBackendName())
|| targetTable.getAssociations() != null && targetTable.getAssociations().stream().anyMatch(a -> !backend.equals(QContext.getQInstance().getTable(a.getAssociatedTableName()).getBackendName())))
{
throw new IllegalArgumentException("Inbound targets and associations must share the EDI transactional backend");
}
String keys = map.getValueString("matchKeyFields");
List<QRecord> matches = List.of();
if(keys != null && !keys.isBlank())
{
QQueryFilter filter = new QQueryFilter().withLimit(2);
for(String key : keys.split(","))
{
key = key.trim();
if(record.getValue(key) == null)
{
throw new IllegalArgumentException("Upsert key is missing");
}
filter.withCriteria(key, QCriteriaOperator.EQUALS, record.getValue(key));
}
QueryInput query = new QueryInput(table).withFilter(filter).withTransaction(QContext.getQBackendTransaction());
query.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
matches = new QueryAction().execute(query).getRecords();
}
if(matches.size() > 1)
{
throw new IllegalArgumentException("Upsert key is ambiguous");
}
QRecord result;
if(matches.isEmpty())
{
InsertInput insert = new InsertInput(table).withRecord(record).withTransaction(QContext.getQBackendTransaction());
insert.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
result = new InsertAction().execute(insert).getRecords().getFirst();
}
else
{
String onMatch = map.getValueString("onMatch");
if("SKIP".equals(onMatch))
{
return (matches.getFirst());
}
if(!"UPDATE".equals(onMatch))
{
throw new IllegalArgumentException("Existing target record is not updateable by this map");
}
String primaryKey = QContext.getQInstance().getTable(table).getPrimaryKeyField();
record.withValue(primaryKey, matches.getFirst().getValue(primaryKey));
UpdateInput update = new UpdateInput(table).withRecord(record).withTransaction(QContext.getQBackendTransaction());
update.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
result = new UpdateAction().execute(update).getRecords().getFirst();
}
if(result.getErrors() != null && !result.getErrors().isEmpty())
{
throw new IllegalArgumentException("Target write failed validation");
}
return (result);
}
}
@@ -1,177 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.map;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import com.kingsrook.qbits.edi.guide.Guide;
import com.kingsrook.qbits.edi.guide.GuideValidator;
import com.kingsrook.qbits.edi.guide.RuleEvaluator;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Inbound mapping preserving loop occurrences, including missing optional values.
*******************************************************************************/
public class InboundMapper
{
private final TransformRegistry transforms;
/*******************************************************************************
** Use the host's transform registry.
*******************************************************************************/
public InboundMapper(TransformRegistry transforms)
{
this.transforms = transforms;
}
/*******************************************************************************
** Mapped records and extracted business identifiers; never log this object.
*******************************************************************************/
public record Result(QRecord record, String businessKey, String secondaryKey)
{
}
/*******************************************************************************
** Load ordered rules for a persisted map.
*******************************************************************************/
public Result map(X12TransactionSet transaction, Guide guide, QRecord map) throws Exception
{
List<QRecord> rules = new ArrayList<>(EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))));
rules.sort(Comparator.comparing(r -> r.getValueInteger("sequence")));
return (map(transaction, guide, map, rules));
}
/*******************************************************************************
** Map in memory; wildcard rules sharing an association use the same loop scope.
*******************************************************************************/
public Result map(X12TransactionSet transaction, Guide guide, QRecord map, List<QRecord> rules) throws Exception
{
Map<String, List<List<X12Segment>>> loops = new GuideValidator().loops(transaction, guide);
RuleEvaluator evaluator = new RuleEvaluator(transaction.segments(), loops);
QRecord result = new QRecord();
for(QRecord rule : rules)
{
try
{
String path = rule.getValueString("ediPath");
String target = rule.getValueString("qqqPath");
String[] fields = target.split("\\.");
if(fields.length > 2 || fields.length == 0)
{
throw new IllegalArgumentException("Invalid target path");
}
if(fields.length == 1)
{
apply(rule, evaluator, result, fields[0], path);
}
else
{
if(path == null || !path.contains("[*]/"))
{
throw new IllegalArgumentException("Association path requires a wildcard loop");
}
String anchor = path.substring(0, path.indexOf("[*]/"));
String relative = path.substring(path.indexOf("[*]/") + 4);
List<List<X12Segment>> occurrences = loops.get(anchor);
if(occurrences == null)
{
occurrences = loops.values().stream().flatMap(List::stream).filter(l -> !l.isEmpty() && l.getFirst().id().equals(anchor)).distinct().toList();
}
List<QRecord> children = result.getAssociatedRecords() == null ? null : result.getAssociatedRecords().get(fields[0]);
if(children == null)
{
children = new ArrayList<>();
for(int i = 0; i < occurrences.size(); i++)
{
children.add(new QRecord());
}
result.withAssociatedRecords(fields[0], children);
}
if(children.size() != occurrences.size())
{
throw new IllegalArgumentException("Association rules use inconsistent loops");
}
for(int i = 0; i < occurrences.size(); i++)
{
apply(rule, new RuleEvaluator(occurrences.get(i), loops), children.get(i), fields[1], relative);
}
}
}
catch(Exception e)
{
throw new IllegalArgumentException("Mapping failed at rule " + rule.getValueInteger("sequence") + " (" + rule.getValueString("ediPath") + " -> " + rule.getValueString("qqqPath") + ")");
}
}
return (new Result(result, key(evaluator, map.getValueString("businessKeyPath")), key(evaluator, map.getValueString("secondaryKeyPath"))));
}
/*******************************************************************************
** Conditions and defaults are evaluated within the current loop occurrence.
*******************************************************************************/
private void apply(QRecord rule, RuleEvaluator evaluator, QRecord target, String field, String path) throws Exception
{
String condition = rule.getValueString("condition");
if(condition != null && !condition.isBlank() && !evaluator.test(condition))
{
return;
}
List<String> values = path == null || path.isBlank() ? List.of() : values(evaluator, path);
if(values.isEmpty() && rule.getValueString("defaultValue") != null)
{
values = List.of(rule.getValueString("defaultValue"));
}
Serializable value = transforms.apply(rule.getValueString("transform"), rule.getValueString("transformArgs"), values);
if(Boolean.TRUE.equals(rule.getValueBoolean("required")) && (value == null || value.toString().isBlank()))
{
throw new IllegalArgumentException("Required mapping input is absent");
}
target.withValue(field, value);
}
/*******************************************************************************
** A path may join several element paths with a plus outside any predicate, in order, so
** a transform such as DATETIME receives a date followed by a time from separate elements.
*******************************************************************************/
private static List<String> values(RuleEvaluator evaluator, String path)
{
List<String> values = new ArrayList<>();
for(String part : path.split("\\+(?![^\\[]*\\])"))
{
values.addAll(evaluator.values(part.trim()));
}
return (values);
}
/*******************************************************************************
** Missing optional business keys remain null.
*******************************************************************************/
private static String key(RuleEvaluator evaluator, String path)
{
List<String> values = path == null || path.isBlank() ? List.of() : evaluator.values(path);
return (values.isEmpty() ? null : values.getFirst());
}
}
@@ -1,157 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.map;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.ack.AckLevel;
import com.kingsrook.qbits.edi.guide.GuideRepository;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.storage.PayloadRef;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLine;
import com.kingsrook.qqq.backend.core.model.actions.processes.ProcessSummaryLineInterface;
import com.kingsrook.qqq.backend.core.model.actions.processes.Status;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** No-op apply preview with ordinary Result lines; PHI never enters process output.
*******************************************************************************/
public class MapPreview
{
/*******************************************************************************
** Parse, validate, acknowledge and map a stored synthetic sample without target writes.
*******************************************************************************/
public ArrayList<ProcessSummaryLineInterface> preview(QRecord map, QRecord sample) throws Exception
{
EdiRuntime runtime = EdiRuntime.current();
boolean phi = List.of("837", "835", "270", "271", "276", "277", "834").contains(map.getValueString("transactionSet"));
phi = phi || com.kingsrook.qbits.edi.support.SystemWrites.call(() -> !EdiRecords.query("edi_agreement_document", EdiRecords.equal("mapId", map.getValue("id"))
.withCriteria("containsPhi", com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator.EQUALS, true)).isEmpty());
var guide = new GuideRepository().load(map.getValueInteger("guideId"));
ArrayList<ProcessSummaryLineInterface> lines = new ArrayList<>();
try(InputStream stream = runtime.payloads().open(new PayloadRef(sample.getValueString("payloadRef"), null, 0)))
{
var interchanges = runtime.reader().read(stream);
lines.add(new ProcessSummaryLine(Status.INFO, interchanges.size(), "interchanges parsed"));
for(var interchange : interchanges)
{
lines.add(new ProcessSummaryLine(Status.INFO, interchange.groups().size(), "functional groups parsed"));
for(var group : interchange.groups())
{
lines.add(new ProcessSummaryLine(Status.INFO, group.transactions().size(), "transaction sets parsed"));
Map<String, List<com.kingsrook.qbits.edi.guide.ValidationFinding>> all = new java.util.LinkedHashMap<>();
for(var transaction : group.transactions())
{
var findings = runtime.validator().validate(transaction, guide, phi);
all.put(transaction.st().value(2), findings);
for(int snip = 1; snip <= 7; snip++)
{
int level = snip;
lines.add(new ProcessSummaryLine(Status.INFO, (int) findings.stream().filter(f -> f.snipLevel() == level).count(), "findings at SNIP " + snip));
}
List<QRecord> rules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id")));
rules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
var mapped = InboundApplier.asService(runtime.config().getServiceSessionSupplier(), () -> new InboundMapper(runtime.transforms()).map(transaction, guide, map, rules));
if(phi)
{
lines.add(new ProcessSummaryLine(Status.INFO, "PHI map result suppressed; no target records written"));
}
else
{
ObjectMapper json = new ObjectMapper().findAndRegisterModules();
String actual = resultJson(mapped.record());
lines.add(new ProcessSummaryLine(Status.INFO, actual));
String expected = sample.getValueString("expectedResultJson");
if(expected != null)
{
diff(lines, "Expected result", json.readTree(expected), json.readTree(actual));
}
Integer revision = map.getValueInteger("revision");
var previous = EdiRecords.query("edi_map", EdiRecords.equal("name", map.getValue("name")));
previous.removeIf(r -> !java.util.Objects.equals(r.getValue("partnerId"), map.getValue("partnerId"))
|| revision == null || r.getValueInteger("revision") == null || r.getValueInteger("revision") >= revision);
previous.sort(java.util.Comparator.comparing((QRecord r) -> r.getValueInteger("revision")).reversed());
if(!previous.isEmpty())
{
QRecord prior = previous.getFirst();
var priorGuide = new GuideRepository().load(prior.getValueInteger("guideId"));
List<QRecord> priorRules = EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", prior.getValue("id")));
priorRules.sort(java.util.Comparator.comparing(r -> r.getValueInteger("sequence")));
var priorResult = InboundApplier.asService(runtime.config().getServiceSessionSupplier(),
() -> new InboundMapper(runtime.transforms()).map(transaction, priorGuide, prior, priorRules));
diff(lines, "Previous revision " + prior.getValueInteger("revision"), json.readTree(resultJson(priorResult.record())), json.readTree(actual));
}
}
}
var ack = phi && group.gs().value(8).startsWith("005010X") ? runtime.ack999().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001")
: runtime.ack997().build(group, all, AckLevel.ALL_TRANSACTIONS, "0001");
lines.add(new ProcessSummaryLine(Status.INFO, phi ? "Acknowledgement preview suppressed for PHI document"
: runtime.writer().write(com.kingsrook.qbits.edi.ack.AckEnvelope.wrap(interchange, group, ack, "1", "1", java.time.LocalDateTime.now(runtime.config().getClock())))));
}
}
}
return (lines);
}
/*******************************************************************************
** Stable record JSON excludes QRecord diagnostics and implementation metadata.
*******************************************************************************/
private static String resultJson(QRecord record) throws Exception
{
Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("values", record.getValues());
Map<String, Object> associations = new java.util.LinkedHashMap<>();
if(record.getAssociatedRecords() != null)
{
for(var entry : record.getAssociatedRecords().entrySet())
{
List<Object> children = new ArrayList<>();
for(QRecord child : entry.getValue())
{
children.add(new ObjectMapper().readTree(resultJson(child)));
}
associations.put(entry.getKey(), children);
}
}
result.put("associations", associations);
return (new ObjectMapper().findAndRegisterModules().writeValueAsString(result));
}
/*******************************************************************************
** Show a structured before/after diff for synthetic samples only.
*******************************************************************************/
private static void diff(ArrayList<ProcessSummaryLineInterface> lines, String label, com.fasterxml.jackson.databind.JsonNode before, com.fasterxml.jackson.databind.JsonNode after) throws Exception
{
if(before.equals(after))
{
lines.add(new ProcessSummaryLine(Status.INFO, label + " matches"));
}
else
{
Map<String, Object> difference = Map.of("comparison", label, "before", before, "after", after);
lines.add(new ProcessSummaryLine(Status.INFO, label + " differs: " + new ObjectMapper().writeValueAsString(difference)));
}
}
}
@@ -1,195 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.map;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.regex.Pattern;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.edi.EdiRuntime;
import com.kingsrook.qbits.edi.guide.RuleEvaluator;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qbits.edi.x12.X12Segment;
import com.kingsrook.qbits.edi.x12.X12TransactionSet;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Builds ordered segments from record associations. Loop paths define hierarchy;
** rules within a loop share its association occurrence and sequence number.
*******************************************************************************/
public class OutboundMapper
{
private static final Pattern ELEMENT = Pattern.compile("([A-Z][A-Z0-9]{1,2})([0-9]{2})(?:-([0-9]+))?");
/*******************************************************************************
** Load a persisted outbound map's ordered rules.
*******************************************************************************/
public X12TransactionSet build(QRecord source, QRecord map) throws Exception
{
return (build(source, map, EdiRecords.query("edi_map_rule", EdiRecords.equal("mapId", map.getValue("id"))), "0001"));
}
/*******************************************************************************
** Pure mapping entry point; the envelope owns transaction control allocation.
*******************************************************************************/
public X12TransactionSet build(QRecord source, QRecord map, List<QRecord> rules, String control) throws Exception
{
if(!"OUTBOUND".equals(map.getValueString("direction")))
{
throw new IllegalArgumentException("An outbound map is required");
}
List<X12Segment> segments = new ArrayList<>();
segments.add(X12Segment.of("ST", 1, map.getValueString("transactionSet"), control));
List<QRecord> sorted = rules.stream().sorted(Comparator.comparing(r -> r.getValueInteger("sequence"))).toList();
emit(source, sorted, "", "", 1, "", new int[] {0}, segments);
segments.add(X12Segment.of("SE", segments.size() + 1, String.valueOf(segments.size() + 1), control));
return (new X12TransactionSet(segments.getFirst(), segments.getLast(), List.copyOf(segments), List.of()));
}
/*******************************************************************************
** Walk each loop once per associated record, retaining the nearest HL parent.
*******************************************************************************/
private void emit(QRecord source, List<QRecord> rules, String prefix, String qPrefix, int sequence, String parent,
int[] hierarchy, List<X12Segment> output) throws Exception
{
Map<String, List<QRecord>> nodes = new LinkedHashMap<>();
for(QRecord rule : rules)
{
String path = rule.getValueString("ediPath").substring(prefix.length());
String key = path.contains("/") ? path.substring(0, path.indexOf('/') + 1) : path.replaceAll("[0-9]{2}(-[0-9]+)?$", "");
nodes.computeIfAbsent(key, ignored -> new ArrayList<>()).add(rule);
}
String currentParent = parent;
for(var node : nodes.entrySet())
{
if(node.getKey().endsWith("/"))
{
String association = null;
for(QRecord rule : node.getValue())
{
if(rule.getValueString("ediPath").substring((prefix + node.getKey()).length()).contains("/"))
{
continue;
}
String path = rule.getValueString("qqqPath");
if(path != null && path.startsWith(qPrefix) && path.substring(qPrefix.length()).contains("."))
{
association = path.substring(qPrefix.length()).split("\\.")[0];
break;
}
}
List<QRecord> children = association == null ? List.of(source) : Objects.requireNonNullElse(source.getAssociatedRecords() == null ? null : source.getAssociatedRecords().get(association), List.of());
int index = 0;
for(QRecord child : children)
{
emit(child, node.getValue(), prefix + node.getKey(), association == null ? qPrefix : qPrefix + association + ".", ++index, currentParent, hierarchy, output);
}
continue;
}
List<String> elements = new ArrayList<>();
String segmentId = null;
for(QRecord rule : node.getValue())
{
String path = rule.getValueString("ediPath");
var match = ELEMENT.matcher(path.substring(path.lastIndexOf('/') + 1));
if(!match.matches() || match.group(3) != null)
{
throw new IllegalArgumentException("Unsupported outbound element path");
}
segmentId = match.group(1);
int position = Integer.parseInt(match.group(2));
if(position < 1 || position > 99)
{
throw new IllegalArgumentException("Invalid outbound element position");
}
while(elements.size() < position)
{
elements.add("");
}
String condition = rule.getValueString("condition");
if(condition != null && !condition.isBlank() && !new RuleEvaluator(output, Map.of()).test(condition))
{
continue;
}
String qPath = rule.getValueString("qqqPath");
Object raw = qPath == null ? null : source.getValue(qPath.startsWith(qPrefix) ? qPath.substring(qPrefix.length()) : qPath);
String transform = Objects.requireNonNullElse(rule.getValueString("transform"), "");
String argsJson = rule.getValueString("transformArgs");
Map<String, String> args = argsJson == null ? Map.of() : new ObjectMapper().readValue(argsJson, new TypeReference<Map<String, String>>() {});
String value;
if(transform.equals("HL"))
{
value = switch(position)
{
case 1 -> String.valueOf(++hierarchy[0]);
case 2 -> parent;
case 3 -> args.get("code");
case 4 -> source.getAssociatedRecords() != null && source.getAssociatedRecords().values().stream().anyMatch(v -> !v.isEmpty()) ? "1" : "0";
default -> throw new IllegalArgumentException("HL supports positions 1 through 4");
};
}
else if(transform.equals("SEQUENCE"))
{
value = String.valueOf(sequence + Integer.parseInt(args.getOrDefault("start", "1")) - 1);
}
else if(raw == null && !transform.equals("CONSTANT"))
{
value = rule.getValueString("defaultValue");
}
else if(transform.equals("DATE"))
{
value = java.time.LocalDate.parse(raw.toString()).format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE);
}
else if(transform.equals("TIME"))
{
value = java.time.LocalTime.parse(raw.toString()).format(java.time.format.DateTimeFormatter.ofPattern("HHmmss"));
}
else if(transform.equals("DECIMAL"))
{
value = new java.math.BigDecimal(raw.toString()).movePointRight(Integer.parseInt(args.getOrDefault("scale", "0"))).stripTrailingZeros().toPlainString();
}
else
{
Object transformed = EdiRuntime.current().transforms().apply(transform, argsJson, raw == null ? List.of() : List.of(raw.toString()));
value = transformed == null ? rule.getValueString("defaultValue") : transformed.toString();
}
if((value == null || value.isBlank()) && Boolean.TRUE.equals(rule.getValueBoolean("required")))
{
throw new IllegalArgumentException("Required outbound field is missing");
}
elements.set(position - 1, Objects.requireNonNullElse(value, ""));
}
if(elements.stream().anyMatch(v -> !v.isEmpty()))
{
output.add(X12Segment.of(segmentId, output.size() + 1, elements.toArray(String[]::new)));
if("HL".equals(segmentId))
{
currentParent = elements.getFirst();
}
}
}
}
}
@@ -1,191 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.map;
import java.io.Serializable;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.Instant;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.OffsetDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeFormatterBuilder;
import java.time.temporal.ChronoField;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.kingsrook.qbits.edi.storage.EdiRecords;
import com.kingsrook.qqq.backend.core.actions.permissions.PermissionEnforcement;
import com.kingsrook.qqq.backend.core.actions.tables.QueryAction;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QueryInput;
/*******************************************************************************
** Named, host-extensible transforms. Arguments are a JSON object, never code.
*******************************************************************************/
public class TransformRegistry
{
private final Map<String, Transform> transforms = new ConcurrentHashMap<>();
/*******************************************************************************
** Transform a selected path's values in the service session.
*******************************************************************************/
@FunctionalInterface
public interface Transform
{
/*******************************************************************************
** Return a QQQ-compatible field value.
*******************************************************************************/
Serializable apply(List<String> values, Map<String, String> args) throws Exception;
}
/*******************************************************************************
** Install built-in inbound transforms and explicit outbound placeholders.
*******************************************************************************/
public TransformRegistry()
{
register("CONSTANT", (v, a) -> a.get("value"));
register("TRIM", (v, a) -> first(v).trim());
register("UPPER", (v, a) -> first(v).toUpperCase(Locale.ROOT));
register("DATE", (v, a) -> date(first(v)));
register("TIME", (v, a) -> time(first(v)).toString());
register("DATETIME", (v, a) -> date(first(v)).atTime(time(v.size() > 1 ? v.get(1) : a.getOrDefault("time", "0000")))
.atZone(ZoneId.of(a.getOrDefault("timezone", "UTC"))).toInstant());
register("DATEPART", (v, a) -> zoned(first(v), a).format(DateTimeFormatter.ofPattern("yyyyMMdd")));
register("TIMEPART", (v, a) -> zoned(first(v), a).format(DateTimeFormatter.ofPattern("HHmmss")));
register("DECIMAL", (v, a) -> new BigDecimal(first(v)).movePointLeft(Integer.parseInt(a.getOrDefault("scale", "0"))));
register("CONCAT", (v, a) -> String.join(a.getOrDefault("separator", ""), v));
register("SPLIT", (v, a) -> first(v).split(java.util.regex.Pattern.quote(a.getOrDefault("separator", ",")), -1)[Integer.parseInt(a.getOrDefault("index", "0"))]);
register("SUBSTRING", (v, a) -> first(v).substring(Integer.parseInt(a.getOrDefault("start", "0")), Integer.parseInt(a.getOrDefault("end", String.valueOf(first(v).length())))));
register("CODE", (v, a) ->
{
String translated = a.get(first(v));
if(translated == null)
{
throw new IllegalArgumentException("Code is not mapped");
}
return (translated);
});
register("QUALIFIER", (v, a) -> first(v));
register("UNIT", (v, a) -> new BigDecimal(first(v)).multiply(new BigDecimal(a.getOrDefault("factor", "1")))
.divide(new BigDecimal(a.getOrDefault("divisor", "1")), Integer.parseInt(a.getOrDefault("scale", "6")), RoundingMode.HALF_UP));
register("LOOKUP", (v, a) ->
{
QueryInput input = new QueryInput(a.get("table")).withFilter(EdiRecords.equal(a.get("key"), first(v)).withLimit(2));
input.setPermissionEnforcement(PermissionEnforcement.ENFORCE);
var rows = new QueryAction().execute(input).getRecords();
if(rows.size() != 1)
{
throw new IllegalArgumentException("Lookup must match exactly one record");
}
return (rows.getFirst().getValue(a.getOrDefault("result", "id")));
});
for(String name : List.of("SEQUENCE", "HL"))
{
register(name, (v, a) ->
{
throw new UnsupportedOperationException("Outbound transform is not available in phase a");
});
}
}
/*******************************************************************************
** Register or replace a named host transform.
*******************************************************************************/
public TransformRegistry register(String name, Transform transform)
{
transforms.put(name.toUpperCase(Locale.ROOT), transform);
return (this);
}
/*******************************************************************************
** Execute a closed registry entry; unknown names fail explicitly.
*******************************************************************************/
public Serializable apply(String name, String args, List<String> values) throws Exception
{
if(name == null || name.isBlank())
{
return (values.isEmpty() ? null : values.getFirst());
}
Transform transform = transforms.get(name.toUpperCase(Locale.ROOT));
if(transform == null)
{
throw new IllegalArgumentException("Unknown transform");
}
Map<String, String> arguments = args == null || args.isBlank() ? Map.of() : new ObjectMapper().readValue(args, new TypeReference<Map<String, String>>() {});
return (transform.apply(values, arguments));
}
/*******************************************************************************
** Require one input value without echoing it in diagnostics.
*******************************************************************************/
private static String first(List<String> values)
{
if(values.isEmpty())
{
throw new IllegalArgumentException("Transform input is missing");
}
return (values.getFirst());
}
/*******************************************************************************
** Outbound DATETIME halves: an instant, or a zone-less timestamp read as UTC, in the partner zone.
*******************************************************************************/
private static ZonedDateTime zoned(String value, Map<String, String> args)
{
Instant instant = value.endsWith("Z") || value.matches(".*[+-][0-9]{2}:[0-9]{2}$") ? OffsetDateTime.parse(value).toInstant() : LocalDateTime.parse(value).toInstant(ZoneOffset.UTC);
return (instant.atZone(ZoneId.of(args.getOrDefault("timezone", "UTC"))));
}
/*******************************************************************************
** Six-digit dates use the fixed 1950-2049 century window.
*******************************************************************************/
private static LocalDate date(String value)
{
DateTimeFormatter format = value.length() == 6 ? new DateTimeFormatterBuilder().appendValueReduced(ChronoField.YEAR, 2, 2, 1950)
.appendPattern("MMdd").toFormatter() : DateTimeFormatter.BASIC_ISO_DATE;
return (LocalDate.parse(value, format));
}
/*******************************************************************************
** X12 HHMM, HHMMSS and fractional seconds.
*******************************************************************************/
private static LocalTime time(String value)
{
String normalized = value.substring(0, 2) + ":" + value.substring(2, 4);
if(value.length() > 4)
{
normalized += ":" + value.substring(4, 6);
if(value.length() > 6)
{
normalized += "." + value.substring(6).replace(".", "");
}
}
return (LocalTime.parse(normalized));
}
}
@@ -1,61 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
/*******************************************************************************
** Default healthcare classification and validation depth when configuring a document.
*******************************************************************************/
public class AgreementDocumentDefaults implements TableCustomizerInterface
{
/*******************************************************************************
** Preserve explicit values and existing edits; default new records by document family.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords)
{
for(QRecord record : records)
{
QRecord merged = new QRecord();
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
merged.getValues().putAll(record.getValues());
boolean healthcare = List.of("837", "835", "270", "271", "276", "277", "834").contains(Objects.toString(merged.getValue("transactionSet"), ""));
if(merged.getValue("containsPhi") == null)
{
record.setValue("containsPhi", healthcare);
}
if(merged.getValue("snipLevel") == null)
{
record.setValue("snipLevel", healthcare ? 4 : 2);
}
}
return (records);
}
}
@@ -1,83 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QQueryFilter;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** RC.5 form filters; no dependency on later dependency-behavior metadata.
*******************************************************************************/
public final class EdiContingencies
{
/*******************************************************************************
** Restrict every partner-scoped form reference to its owning context.
*******************************************************************************/
public static void apply(List<QTableMetaData> tables)
{
for(QTableMetaData table : tables)
{
for(var field : table.getFields().values())
{
String source = field.getPossibleValueSourceName();
if(source == null)
{
continue;
}
QQueryFilter filter = null;
if(table.getFields().containsKey("partnerId") && List.of("edi_identity", "edi_transport", "edi_certificate", "edi_guide", "edi_map").contains(source))
{
filter = new QQueryFilter(new QFilterCriteria("partnerId", QCriteriaOperator.EQUALS, "${input.partnerId}"));
if(List.of("localIdentityId", "localCertificateId").contains(field.getName()))
{
filter = new QQueryFilter(new QFilterCriteria("partnerId", QCriteriaOperator.IS_BLANK));
}
else if(List.of("edi_transport", "edi_guide", "edi_map").contains(source))
{
filter = new QQueryFilter().withSubFilter(filter).withCriteria("partnerId", QCriteriaOperator.IS_BLANK)
.withBooleanOperator(QQueryFilter.BooleanOperator.OR);
}
}
if(table.getName().equals("edi_map") && field.getName().equals("guideId"))
{
filter = new QQueryFilter().withSubFilter(filter).withCriteria("transactionSet", QCriteriaOperator.EQUALS, "${input.transactionSet}");
}
if(table.getName().equals("edi_agreement_document") && List.of("guideId", "mapId").contains(field.getName()))
{
filter = new QQueryFilter().withCriteria("transactionSet", QCriteriaOperator.EQUALS, "${input.transactionSet}")
.withCriteria("version", QCriteriaOperator.EQUALS, "${input.version}");
}
if(table.getName().equals("edi_interchange") && field.getName().equals("originalInterchangeId"))
{
filter = new QQueryFilter().withCriteria("partnerId", QCriteriaOperator.EQUALS, "${input.partnerId}")
.withCriteria("agreementId", QCriteriaOperator.EQUALS, "${input.agreementId}").withCriteria("direction", QCriteriaOperator.EQUALS, "OUTBOUND");
}
if(filter != null)
{
field.withPossibleValueSourceFilter(filter);
}
}
}
}
}
@@ -1,90 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import com.kingsrook.qbits.edi.EdiQBitConfig;
import com.kingsrook.qbits.edi.EdiQBitProducer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducerMultiOutput;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.qbits.QBitComponentMetaDataProducer;
import com.kingsrook.qqq.backend.core.model.metadata.security.QSecurityKeyType;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
/*******************************************************************************
** Install tables, relations, record widgets and value sources.
*******************************************************************************/
public class EdiMetaDataProducer extends QBitComponentMetaDataProducer<MetaDataProducerMultiOutput, EdiQBitConfig>
{
/*******************************************************************************
** Only run when configured by the owning producer.
*******************************************************************************/
@Override
public boolean isEnabled()
{
return (getQBitConfig() != null);
}
/*******************************************************************************
** Produce tables, the typed app home, record actions and alert types.
*******************************************************************************/
@Override
public MetaDataProducerMultiOutput produce(QInstance instance) throws QException
{
MetaDataProducerMultiOutput output = new MetaDataProducerMultiOutput();
EdiTables.possibleValueSources().forEach(output::add);
EdiTables.joins().forEach(output::add);
EdiTables.childWidgets().forEach(output::add);
output.add(new QSecurityKeyType().withName("edi_partner").withAllAccessKeyName("edi.admin"));
output.add(new QSecurityKeyType().withName("edi_guide_source").withAllAccessKeyName("edi.guide.loader"));
java.util.List<QTableMetaData> tables = EdiTables.tables(getQBitConfig());
for(QTableMetaData table : tables)
{
table.setSourceQBitName(instance.getQBits().values().stream().filter(q -> EdiQBitProducer.ARTIFACT_ID.equals(q.getArtifactId())).findFirst().orElseThrow().getName());
output.add(table);
}
if(getQBitConfig().getEsbDestinationName() != null && !getQBitConfig().getEsbDestinationName().isBlank())
{
instance.withRecordChangeListener(new com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference(com.kingsrook.qbits.edi.support.EdiDocumentEvents.class));
}
output.add(com.kingsrook.qbits.edi.app.EdiHome.app(getQBitConfig(), tables));
com.kingsrook.qbits.edi.app.EdiHome.widgets().forEach(output::add);
for(String name : java.util.List.of("edi.inbox.dead-letter", "edi.ack.overdue", "edi.ack.rejected", "edi.send.failed", "edi.certificate.expiring", "edi.certificate.expired", "edi.mdn.overdue"))
{
output.add(new com.kingsrook.qqq.backend.core.alerts.QAlertTypeMetaData().withName(name).withLabel(name)
.withSourceName("edi").withDefaultSeverity(com.kingsrook.qqq.backend.core.alerts.AlertSeverity.ERROR)
.withDescription("EDI operational exception").withHowToFix("Review the related EDI records")
.withVisibilityPermission("edi.exception.read"));
}
com.kingsrook.qbits.edi.processes.EdiProcesses.processes(getQBitConfig()).forEach(output::add);
output.add(new com.kingsrook.qqq.backend.core.model.metadata.QBackendMetaData().withName("edi_preview_download")
.withBackendType(com.kingsrook.qbits.edi.storage.PreviewDownloadBackend.class));
output.add(new QTableMetaData().withName("edi_preview_download").withLabel("Preview Download").withBackendName("edi_preview_download")
.withPrimaryKeyField("id").withField(new com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData("id", com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType.INTEGER).withLabel("ID"))
.withPermissionRules(new com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules().withPermissionBaseName("edi.map.preview")
.withLevel(com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel.HAS_ACCESS_PERMISSION))
.withoutCapabilities(com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_INSERT,
com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_UPDATE, com.kingsrook.qqq.backend.core.model.metadata.tables.Capability.TABLE_DELETE));
return (output);
}
}
@@ -1,115 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import java.util.List;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.model.actions.tables.QueryOrGetInputInterface;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.QSupplementalInstanceMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
/*******************************************************************************
** Stable operator labels, including a PHI-safe fallback for documents without keys.
*******************************************************************************/
public class EdiRecordLabels implements TableCustomizerInterface, QSupplementalInstanceMetaData
{
/*******************************************************************************
** Instance enrichment runs after core has supplied default table formats.
*******************************************************************************/
@Override
public String getName()
{
return ("edi.recordLabels");
}
/*******************************************************************************
** Keep search/security label fields, while letting the query customizer supply
** the document label. RC.5 otherwise generates a fixed format from those fields.
*******************************************************************************/
@Override
public void enrich(QInstance instance)
{
instance.getTable("edi_transaction").setRecordLabelFormat(null);
}
/*******************************************************************************
** Use native formats except where the label depends on available business keys.
*******************************************************************************/
public static void configure(QTableMetaData table)
{
switch(table.getName())
{
case "edi_identity" -> table.withRecordLabelFormatAndFields("%s %s", "interchangeId", "applicationCode");
case "edi_agreement" -> table.withRecordLabelFormatAndFields("%s %s", "partnerId", "usageIndicator");
case "edi_agreement_document" -> table.withRecordLabelFormatAndFields("%s %s %s", "transactionSet", "direction", "version");
case "edi_control_number" -> table.withRecordLabelFormatAndFields("%s %s", "level", "lastValue");
case "edi_guide", "edi_map" -> table.withRecordLabelFormatAndFields("%s r%s", "name", "revision");
case "edi_guide_rule" -> table.withRecordLabelFormatAndFields("%s: %s", "type", "message");
case "edi_map_rule" -> table.withRecordLabelFormatAndFields("%s: %s", "ediPath", "qqqPath");
case "edi_interchange" -> table.withRecordLabelFormatAndFields("%s ISA %s", "direction", "controlNumber");
case "edi_functional_group" -> table.withRecordLabelFormatAndFields("GS %s", "controlNumber");
case "edi_exception" -> table.withRecordLabelFormatAndFields("%s: %s", "type", "summary");
case "edi_inbox" -> table.withRecordLabelFormatAndFields("%s %s", "transportType", "externalId");
case "edi_outbound_message" -> table.withRecordLabelFormatAndFields("%s %s", "transportId", "status");
case "edi_as2_mdn" -> table.withRecordLabelFormatAndFields("%s MDN %s", "direction", "originalMessageId");
case "edi_outbound_request" -> table.withRecordLabelFormatAndFields("%s %s %s", "sourceTableName", "sourceRecordId", "status");
case "edi_transaction_error" -> table.withRecordLabelFormatAndFields("%s: %s", "severity", "message");
case "edi_event" -> table.withRecordLabelFormatAndFields("%s %s", "type", "at");
case "edi_transaction" -> table.withRecordLabelFormat(null)
.withRecordLabelFields("transactionSet", "businessKeyType", "businessKey", "controlNumber", "containsPhi")
.withCustomizer(TableCustomizers.POST_QUERY_RECORD, new QCodeReference(EdiRecordLabels.class));
default ->
{
// Tables with a natural name retain their base record-label field.
}
}
}
/*******************************************************************************
** Queries, record reads and table value sources all use this same fallback.
*******************************************************************************/
@Override
public List<QRecord> postQuery(QueryOrGetInputInterface input, List<QRecord> records)
{
for(QRecord record : records)
{
String key = record.getValueString("businessKey");
String type = record.getValueString("businessKeyType");
String suffix = !Boolean.TRUE.equals(record.getValueBoolean("containsPhi")) && StringUtils.hasContent(key) && StringUtils.hasContent(type)
? type + " " + key : "ST " + record.getValueString("controlNumber");
record.setRecordLabel(record.getValueString("transactionSet") + " " + suffix);
}
return (records);
}
}
@@ -1,207 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QIcon;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
/*******************************************************************************
** Operator vocabulary and scan order shared by lists, records and child widgets.
*******************************************************************************/
public final class EdiTablePresentation
{
private static final Set<String> ACRONYMS = Set.of("ID", "PHI", "PII", "URL", "ISA", "GS", "ST", "AS2", "SFTP", "HTTPS", "HTTP", "MDN", "TLS", "TA1", "EDI", "UPC", "NPI", "QQQ", "JSON", "PEM", "SHA256", "SNIP", "VAN", "AK3", "AK4", "IK3", "IK4", "MIC");
/*******************************************************************************
** Explicit singular names; unknown tables must declare their vocabulary.
*******************************************************************************/
public static String label(String table)
{
return (switch(table)
{
case "edi_partner" -> "Trading Partner";
case "edi_identity" -> "EDI Identity";
case "edi_agreement" -> "Agreement";
case "edi_agreement_document" -> "Agreement Document";
case "edi_control_number" -> "Control Number";
case "edi_transport" -> "Transport";
case "edi_certificate" -> "Certificate";
case "edi_guide" -> "Implementation Guide";
case "edi_guide_segment" -> "Guide Segment";
case "edi_guide_element" -> "Guide Element";
case "edi_guide_rule" -> "Guide Rule";
case "edi_code_list" -> "Code List";
case "edi_code_list_value" -> "Code";
case "edi_map" -> "Map";
case "edi_map_rule" -> "Map Rule";
case "edi_map_sample" -> "Map Sample";
case "edi_interchange" -> "Interchange";
case "edi_functional_group" -> "Functional Group";
case "edi_transaction" -> "Document";
case "edi_transaction_error" -> "Validation Error";
case "edi_event" -> "Event";
case "edi_exception" -> "Exception";
case "edi_inbox" -> "Inbound Message";
case "edi_outbound_request" -> "Outbound Request";
case "edi_outbound_message" -> "Outbound Message";
case "edi_as2_mdn" -> "AS2 MDN";
case "edi_payload" -> "Payload";
default -> throw new IllegalArgumentException("Missing EDI table label: " + table);
});
}
/*******************************************************************************
** Child sections use the plain plural, without database relationship syntax.
*******************************************************************************/
public static String pluralLabel(String table)
{
return (switch(table)
{
case "edi_identity" -> "Identities";
case "edi_guide" -> "Guides";
case "edi_guide_segment" -> "Segments";
case "edi_guide_element" -> "Elements";
case "edi_guide_rule" -> "Rules";
case "edi_map_sample" -> "Samples";
default -> label(table) + "s";
});
}
/*******************************************************************************
** Keep field insertion order and record sections aligned, with dates last.
*******************************************************************************/
public static void apply(QTableMetaData table)
{
table.setLabel(label(table.getName()));
List<String> summary = leadingFields(table.getName());
Map<String, QFieldMetaData> ordered = new LinkedHashMap<>();
summary.forEach(name -> ordered.put(name, table.getField(name)));
table.getFields().forEach(ordered::putIfAbsent);
List<String> details = new ArrayList<>();
List<String> dates = new ArrayList<>();
for(QFieldMetaData field : ordered.values())
{
field.setLabel(fieldLabel(field));
if(summary.contains(field.getName()) || Boolean.TRUE.equals(field.getIsHidden()))
{
continue;
}
(field.getType() == QFieldType.DATE_TIME || field.getType() == QFieldType.DATE ? dates : details).add(field.getName());
}
Map<String, QFieldMetaData> fields = new LinkedHashMap<>();
for(List<String> section : List.of(summary, details, dates))
{
section.forEach(name -> fields.put(name, ordered.get(name)));
}
ordered.forEach(fields::putIfAbsent);
table.setFields(fields);
table.setSections(new ArrayList<>(List.of(
new QFieldSection("identity", new QIcon("badge"), Tier.T1, new ArrayList<>(summary)).withLabel("Overview"),
new QFieldSection("details", new QIcon("description"), Tier.T2, details).withLabel("Details"),
new QFieldSection("dates", new QIcon("schedule"), Tier.T3, dates).withLabel("Dates"))));
}
/*******************************************************************************
** References name the linked record; external identifiers retain the ID suffix.
*******************************************************************************/
private static String fieldLabel(QFieldMetaData field)
{
String name = switch(field.getName())
{
case "transactionId" -> "document";
case "ackTransactionId" -> "ackDocument";
case "inboxId" -> "inboundMessage";
case "assignedToUserId" -> "assignedTo";
default -> field.getName();
};
if(name.endsWith("Id") && field.getPossibleValueSourceName() != null)
{
name = name.substring(0, name.length() - 2);
}
List<String> words = new ArrayList<>();
for(String word : name.replaceAll("([a-z0-9])([A-Z])", "$1 $2").split(" "))
{
String upper = word.toUpperCase(Locale.ROOT);
words.add(ACRONYMS.contains(upper) ? upper : Character.toUpperCase(word.charAt(0)) + word.substring(1));
}
return (String.join(" ", words));
}
/*******************************************************************************
** The first columns answer what happened, to whom, and what needs attention.
*******************************************************************************/
private static List<String> leadingFields(String table)
{
return (switch(table)
{
case "edi_partner" -> List.of("name", "code", "status", "contactName", "contactEmail", "contactPhone", "containsPhi");
case "edi_identity" -> List.of("interchangeId", "interchangeQualifier", "applicationCode", "partnerId", "standard", "isActive");
case "edi_agreement" -> List.of("partnerId", "localIdentityId", "partnerIdentityId", "standard", "usageIndicator", "isActive", "inboundTransportId", "outboundTransportId");
case "edi_agreement_document" -> List.of("transactionSet", "direction", "version", "agreementId", "isActive", "guideId", "mapId", "ackExpectation");
case "edi_control_number" -> List.of("level", "lastValue", "agreementId", "localIdentityId", "minValue", "maxValue", "wrapAllowed");
case "edi_transport" -> List.of("name", "type", "direction", "partnerId", "isActive");
case "edi_certificate" -> List.of("alias", "status", "usage", "partnerId", "notAfter", "subject", "issuer");
case "edi_guide" -> List.of("name", "revision", "status", "transactionSet", "version", "standard", "partnerId", "source");
case "edi_guide_segment" -> List.of("segmentId", "name", "position", "sequence", "usage", "loopId", "guideId");
case "edi_guide_element" -> List.of("name", "elementId", "position", "componentPosition", "usage", "dataType", "guideSegmentId");
case "edi_guide_rule" -> List.of("type", "message", "severity", "positions", "guideId", "guideSegmentId");
case "edi_code_list" -> List.of("name", "elementId", "source", "externalCodeSet");
case "edi_code_list_value" -> List.of("code", "description", "isActive", "codeListId");
case "edi_map" -> List.of("name", "revision", "status", "direction", "transactionSet", "version", "partnerId", "guideId", "tableName");
case "edi_map_rule" -> List.of("sequence", "ediPath", "qqqPath", "transform", "required", "mapId");
case "edi_map_sample" -> List.of("name", "mapId", "lastPreviewStatus", "lastPreviewAt");
case "edi_interchange" -> List.of("direction", "partnerId", "controlNumber", "status", "interchangeDate", "transportId", "groupCount");
case "edi_functional_group" -> List.of("controlNumber", "functionalIdCode", "version", "transactionCount", "ackStatus", "interchangeId");
case "edi_transaction" -> List.of("transactionSet", "businessKeyType", "businessKey", "partnerId", "direction", "status", "ackStatus", "version", "interchangeId");
case "edi_transaction_error" -> List.of("severity", "message", "segmentId", "segmentPosition", "elementPosition", "transactionId");
case "edi_event" -> List.of("type", "at", "detail", "transactionId", "interchangeId", "inboxId");
case "edi_exception" -> List.of("type", "status", "summary", "partnerId", "transactionId", "interchangeId", "assignedToUserId", "occurrences");
case "edi_inbox" -> List.of("transportType", "externalId", "status", "partnerId", "transportId", "receivedAt", "attempts");
case "edi_outbound_request" -> List.of("status", "agreementDocumentId", "sourceTableName", "sourceRecordId", "attempts", "nextAttemptAt");
case "edi_outbound_message" -> List.of("transportId", "status", "partnerId", "agreementId", "attempts", "sentAt", "deliveredAt");
case "edi_as2_mdn" -> List.of("direction", "originalMessageId", "disposition", "signed", "signatureValid", "partnerId", "transportId", "receivedAt");
case "edi_payload" -> List.of("kind", "sizeBytes", "sha256");
default -> throw new IllegalArgumentException("Missing EDI field order: " + table);
});
}
}
@@ -1,460 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import java.util.ArrayList;
import java.util.List;
import com.kingsrook.qbits.edi.EdiQBitConfig;
import com.kingsrook.qbits.edi.model.EdiAgreement;
import com.kingsrook.qbits.edi.model.EdiAgreementDocument;
import com.kingsrook.qbits.edi.model.EdiAs2Mdn;
import com.kingsrook.qbits.edi.model.EdiCertificate;
import com.kingsrook.qbits.edi.model.EdiCodeList;
import com.kingsrook.qbits.edi.model.EdiCodeListValue;
import com.kingsrook.qbits.edi.model.EdiControlNumber;
import com.kingsrook.qbits.edi.model.EdiEvent;
import com.kingsrook.qbits.edi.model.EdiException;
import com.kingsrook.qbits.edi.model.EdiFunctionalGroup;
import com.kingsrook.qbits.edi.model.EdiGuide;
import com.kingsrook.qbits.edi.model.EdiGuideElement;
import com.kingsrook.qbits.edi.model.EdiGuideRule;
import com.kingsrook.qbits.edi.model.EdiGuideSegment;
import com.kingsrook.qbits.edi.model.EdiIdentity;
import com.kingsrook.qbits.edi.model.EdiInbox;
import com.kingsrook.qbits.edi.model.EdiInterchange;
import com.kingsrook.qbits.edi.model.EdiMap;
import com.kingsrook.qbits.edi.model.EdiMapRule;
import com.kingsrook.qbits.edi.model.EdiMapSample;
import com.kingsrook.qbits.edi.model.EdiOutboundMessage;
import com.kingsrook.qbits.edi.model.EdiOutboundRequest;
import com.kingsrook.qbits.edi.model.EdiPartner;
import com.kingsrook.qbits.edi.model.EdiPayload;
import com.kingsrook.qbits.edi.model.EdiTransaction;
import com.kingsrook.qbits.edi.model.EdiTransactionError;
import com.kingsrook.qbits.edi.model.EdiTransport;
import com.kingsrook.qbits.edi.security.EdiClassifications;
import com.kingsrook.qqq.backend.core.actions.ai.AiExposure;
import com.kingsrook.qqq.backend.core.actions.ai.AiFieldMetaData;
import com.kingsrook.qqq.backend.core.actions.ai.AiTableMetaData;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizers;
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ChildRecordListRenderer;
import com.kingsrook.qqq.backend.core.exceptions.QException;
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterOrderBy;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
import com.kingsrook.qqq.backend.core.model.metadata.audits.AuditLevel;
import com.kingsrook.qqq.backend.core.model.metadata.audits.QAuditRules;
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinOn;
import com.kingsrook.qqq.backend.core.model.metadata.joins.JoinType;
import com.kingsrook.qqq.backend.core.model.metadata.joins.QJoinMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.PermissionLevel;
import com.kingsrook.qqq.backend.core.model.metadata.permissions.QPermissionRules;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValue;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSource;
import com.kingsrook.qqq.backend.core.model.metadata.possiblevalues.QPossibleValueSourceType;
import com.kingsrook.qqq.backend.core.model.metadata.security.RecordSecurityLock;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Capability;
import com.kingsrook.qqq.backend.core.model.metadata.tables.ExposedJoin;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QFieldSection;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.Tier;
import com.kingsrook.qqq.backend.core.model.metadata.tables.UniqueKey;
/*******************************************************************************
** Tables, relations and value sources for the EDI data model.
*******************************************************************************/
public final class EdiTables
{
/*******************************************************************************
** Build annotated tables and complete record screens.
*******************************************************************************/
public static List<QTableMetaData> tables(EdiQBitConfig config) throws QException
{
List<QTableMetaData> tables = new ArrayList<>();
QTableMetaData partner = base(config, EdiPartner.TABLE_NAME, EdiPartner.class, "name", false);
partner.withUniqueKey(new UniqueKey("name"));
partner.withUniqueKey(new UniqueKey("code"));
tables.add(partner);
QTableMetaData identity = base(config, EdiIdentity.TABLE_NAME, EdiIdentity.class, "id", false);
identity.withUniqueKey(new UniqueKey("standard", "interchangeQualifier", "interchangeId", "applicationCode"));
tables.add(identity);
QTableMetaData agreement = base(config, EdiAgreement.TABLE_NAME, EdiAgreement.class, "id", false);
agreement.withUniqueKey(new UniqueKey("localIdentityId", "partnerIdentityId", "standard", "usageIndicator"));
tables.add(agreement);
QTableMetaData agreementDocument = base(config, EdiAgreementDocument.TABLE_NAME, EdiAgreementDocument.class, "id", false);
agreementDocument.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(AgreementDocumentDefaults.class))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(AgreementDocumentDefaults.class));
agreementDocument.withUniqueKey(new UniqueKey("agreementId", "direction", "transactionSet", "version"));
tables.add(agreementDocument);
QTableMetaData controlNumber = base(config, EdiControlNumber.TABLE_NAME, EdiControlNumber.class, "id", false);
controlNumber.getField("scopeKey").withIsHidden(true).withIsEditable(false);
controlNumber.getSections().forEach(section -> section.getFieldNames().remove("scopeKey"));
controlNumber.withUniqueKey(new UniqueKey("scopeKey", "level"));
tables.add(controlNumber);
QTableMetaData transport = base(config, EdiTransport.TABLE_NAME, EdiTransport.class, "name", false);
transport.withUniqueKey(new UniqueKey("name"));
transport.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(TransportValidation.class))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(TransportValidation.class));
tables.add(transport);
QTableMetaData certificate = base(config, EdiCertificate.TABLE_NAME, EdiCertificate.class, "alias", false);
certificate.withCustomizer(TableCustomizers.PRE_INSERT_RECORD, new QCodeReference(com.kingsrook.qbits.edi.as2.CertificateCustomizer.class))
.withCustomizer(TableCustomizers.PRE_UPDATE_RECORD, new QCodeReference(com.kingsrook.qbits.edi.as2.CertificateCustomizer.class));
tables.add(certificate);
QTableMetaData guide = base(config, EdiGuide.TABLE_NAME, EdiGuide.class, "name", false);
guide.withUniqueKey(new UniqueKey("name", "revision"));
guide.withRecordSecurityLock(new RecordSecurityLock().withFieldName("source").withSecurityKeyType("edi_guide_source").withLockScope(RecordSecurityLock.LockScope.WRITE));
tables.add(guide);
QTableMetaData guideSegment = base(config, EdiGuideSegment.TABLE_NAME, EdiGuideSegment.class, "name", false);
tables.add(guideSegment);
QTableMetaData guideElement = base(config, EdiGuideElement.TABLE_NAME, EdiGuideElement.class, "name", false);
tables.add(guideElement);
QTableMetaData guideRule = base(config, EdiGuideRule.TABLE_NAME, EdiGuideRule.class, "id", false);
tables.add(guideRule);
QTableMetaData codeList = base(config, EdiCodeList.TABLE_NAME, EdiCodeList.class, "name", false);
tables.add(codeList);
QTableMetaData codeListValue = base(config, EdiCodeListValue.TABLE_NAME, EdiCodeListValue.class, "code", false);
codeListValue.withUniqueKey(new UniqueKey("codeListId", "code"));
tables.add(codeListValue);
QTableMetaData interchange = base(config, EdiInterchange.TABLE_NAME, EdiInterchange.class, "controlNumber", true);
tables.add(interchange);
QTableMetaData functionalGroup = base(config, EdiFunctionalGroup.TABLE_NAME, EdiFunctionalGroup.class, "controlNumber", true);
tables.add(functionalGroup);
QTableMetaData transaction = base(config, EdiTransaction.TABLE_NAME, EdiTransaction.class, "controlNumber", true);
tables.add(transaction);
QTableMetaData transactionError = base(config, EdiTransactionError.TABLE_NAME, EdiTransactionError.class, "id", true);
tables.add(transactionError);
QTableMetaData event = base(config, EdiEvent.TABLE_NAME, EdiEvent.class, "id", true);
tables.add(event);
QTableMetaData exception = base(config, EdiException.TABLE_NAME, EdiException.class, "summary", false);
tables.add(exception);
QTableMetaData inbox = base(config, EdiInbox.TABLE_NAME, EdiInbox.class, "id", true);
inbox.withUniqueKey(new UniqueKey("transportType", "externalId"));
tables.add(inbox);
QTableMetaData payload = base(config, EdiPayload.TABLE_NAME, EdiPayload.class, "kind", true);
tables.add(payload);
tables.add(base(config, "edi_map", EdiMap.class, "name", false));
tables.add(base(config, "edi_map_rule", EdiMapRule.class, "id", false));
tables.add(base(config, "edi_map_sample", EdiMapSample.class, "name", false));
tables.add(base(config, "edi_outbound_request", EdiOutboundRequest.class, "id", true).withUniqueKey(new UniqueKey("agreementDocumentId", "eventId")));
tables.add(base(config, "edi_outbound_message", EdiOutboundMessage.class, "id", true));
tables.add(base(config, EdiAs2Mdn.TABLE_NAME, EdiAs2Mdn.class, "originalMessageId", true));
for(QTableMetaData table : tables)
{
List<String> guidePath = switch(table.getName())
{
case "edi_guide_segment" -> List.of("edi_guide_guide_id_edi_guide_segment");
case "edi_guide_rule" -> List.of("edi_guide_guide_id_edi_guide_rule");
case "edi_guide_element" -> List.of("edi_guide_guide_id_edi_guide_segment", "edi_guide_segment_guide_segment_id_edi_guide_element");
default -> List.of();
};
if(!guidePath.isEmpty())
{
table.withRecordSecurityLock(new RecordSecurityLock().withFieldName("edi_guide.source").withSecurityKeyType("edi_guide_source")
.withLockScope(RecordSecurityLock.LockScope.WRITE).withJoinNameChain(guidePath));
}
if(config.getPartnerLocks() && !table.getFields().containsKey("partnerId"))
{
List<String> path = switch(table.getName())
{
case "edi_agreement_document" -> List.of("edi_agreement_agreement_id_edi_agreement_document");
case "edi_control_number" -> List.of("edi_agreement_agreement_id_edi_control_number");
case "edi_functional_group" -> List.of("edi_interchange_interchange_id_edi_functional_group");
case "edi_transaction_error" -> List.of("edi_transaction_transaction_id_edi_transaction_error");
case "edi_event" -> List.of("edi_interchange_interchange_id_edi_event");
case "edi_map_rule" -> List.of("edi_map_map_id_edi_map_rule");
case "edi_map_sample" -> List.of("edi_map_map_id_edi_map_sample");
case "edi_guide_segment" -> List.of("edi_guide_guide_id_edi_guide_segment");
case "edi_guide_rule" -> List.of("edi_guide_guide_id_edi_guide_rule");
case "edi_guide_element" -> List.of("edi_guide_guide_id_edi_guide_segment", "edi_guide_segment_guide_segment_id_edi_guide_element");
default -> List.of();
};
if(!path.isEmpty())
{
String parent = switch(table.getName())
{
case "edi_agreement_document", "edi_control_number" -> "edi_agreement";
case "edi_functional_group", "edi_event" -> "edi_interchange";
case "edi_transaction_error" -> "edi_transaction";
case "edi_map_rule", "edi_map_sample" -> "edi_map";
default -> "edi_guide";
};
table.withRecordSecurityLock(new RecordSecurityLock().withFieldName(parent + ".partnerId").withSecurityKeyType("edi_partner").withJoinNameChain(path));
}
}
for(QJoinMetaData join : joins())
{
if(join.getLeftTable().equals(table.getName()))
{
table.withExposedJoin(new ExposedJoin().withLabel(joinLabel(join)).withJoinTable(join.getRightTable()).withJoinPath(List.of(join.getName())));
table.withSection(new QFieldSection().withName(join.getName()).withLabel(joinLabel(join)).withTier(Tier.T2).withWidgetName(join.getName()));
}
}
}
for(QTableMetaData table : tables)
{
if(com.kingsrook.qbits.edi.app.EdiHome.ACTIVITY.contains(table.getName()) || com.kingsrook.qbits.edi.app.EdiHome.SETUP.contains(table.getName()))
{
table.withSearchFields(table.getFields().values().stream().filter(f -> List.of("id", "name", "code", "controlNumber", "businessKey", "secondaryKey", "summary", "alias").contains(f.getName())).map(QFieldMetaData::getName).toList());
}
}
tables.forEach(t -> com.kingsrook.qbits.edi.support.EdiIntegrations.configure(t, config.getEsbDestinationName()));
EdiContingencies.apply(tables);
return (tables);
}
/*******************************************************************************
** Common permissions, audit, field names and optional partner isolation.
*******************************************************************************/
private static QTableMetaData base(EdiQBitConfig config, String name, Class<? extends QRecordEntity> entity, String label, boolean readOnly) throws QException
{
QTableMetaData table = new QTableMetaData().withName(name).withBackendName(config.getBackendName())
.withPrimaryKeyField("id").withFieldsFromEntity(entity).withRecordLabelFormatAndFields("%s", label)
.withPermissionRules(new QPermissionRules().withPermissionBaseName("edi." + name.substring(4)).withLevel(PermissionLevel.READ_INSERT_EDIT_DELETE_PERMISSIONS))
.withAuditRules(new QAuditRules().withAuditLevel(readOnly ? AuditLevel.RECORD : AuditLevel.FIELD));
QRecordEntity.registerTable(entity, table);
if(List.of("edi_payload", "edi_certificate", "edi_inbox").contains(name))
{
table.withSupplementalMetaData(new AiTableMetaData().withExposure(AiExposure.NONE));
}
if(name.equals("edi_exception"))
{
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_DELETE);
table.getFields().values().forEach(field -> field.setIsEditable(List.of("status", "assignedToUserId", "resolution").contains(field.getName())));
}
if(readOnly)
{
table.withoutCapabilities(Capability.TABLE_INSERT, Capability.TABLE_UPDATE, Capability.TABLE_DELETE);
}
if(config.getPartnerLocks() && table.getFields().containsKey("partnerId"))
{
table.withRecordSecurityLock(new RecordSecurityLock().withFieldName("partnerId").withSecurityKeyType("edi_partner"));
}
for(QFieldMetaData field : table.getFields().values())
{
String classification = List.of("phiBusinessKey", "fileName", "ciphertext", "expectedResultJson", "valueExcerpt").contains(field.getName()) ? "PHI"
: List.of("contactName", "contactEmail", "contactPhone").contains(field.getName()) ? "PII" : "CONFIDENTIAL";
field.withSupplementalMetaData(new EdiClassifications(classification));
if(classification.equals("PHI"))
{
field.withSupplementalMetaData(new AiFieldMetaData().withExposure(AiExposure.NONE));
}
}
EdiTablePresentation.apply(table);
EdiRecordLabels.configure(table);
return (table);
}
/*******************************************************************************
** One source for each enumeration and referenced table.
*******************************************************************************/
public static List<QPossibleValueSource> possibleValueSources()
{
List<QPossibleValueSource> sources = new ArrayList<>();
sources.add(enumSource("edi_standard", "X12", "EDIFACT"));
sources.add(enumSource("edi_partner_status", "TESTING", "ACTIVE", "SUSPENDED", "RETIRED"));
sources.add(enumSource("edi_usage_indicator", "T", "P"));
sources.add(enumSource("edi_line_ending", "NONE", "LF", "CRLF"));
sources.add(enumSource("edi_send_ta1", "NEVER", "ON_REQUEST", "ON_ERROR", "ALWAYS"));
sources.add(enumSource("edi_control_number_scope", "AGREEMENT", "LOCAL_IDENTITY"));
sources.add(enumSource("edi_on_duplicate", "REJECT_TA1", "IGNORE", "PROCESS"));
sources.add(enumSource("edi_direction", "INBOUND", "OUTBOUND"));
sources.add(enumSource("edi_transport_direction", "INBOUND", "OUTBOUND", "BOTH"));
sources.add(enumSource("edi_ack_expectation", "NONE", "997", "999"));
sources.add(enumSource("edi_ack_level", "GROUP_ONLY", "ALL_TRANSACTIONS", "ERRORS_ONLY"));
sources.add(enumSource("edi_batch_mode", "IMMEDIATE", "BATCH"));
sources.add(enumSource("edi_outbound_trigger", "ESB_EVENT", "RECORD_CHANGE", "MANUAL"));
sources.add(enumSource("edi_level", "ISA", "GS"));
sources.add(enumSource("edi_transport_type", "AS2", "SFTP", "GATEWAY", "HTTPS", "EMAIL"));
sources.add(enumSource("edi_sign_algorithm", "SHA256", "SHA384", "SHA512", "SHA1"));
sources.add(enumSource("edi_encrypt_algorithm", "AES128_CBC", "AES256_CBC", "AES128_GCM", "AES256_GCM", "3DES"));
sources.add(enumSource("edi_mdn_mode", "SYNC", "ASYNC", "NONE"));
sources.add(enumSource("edi_sftp_after_pickup", "DELETE", "MOVE"));
sources.add(enumSource("edi_https_auth_type", "NONE", "BASIC", "BEARER", "HMAC", "MTLS"));
sources.add(enumSource("edi_https_inbound_auth", "BASIC", "BEARER", "HMAC"));
sources.add(enumSource("edi_certificate_status", "ACTIVE", "NEXT", "RETIRED"));
sources.add(enumSource("edi_certificate_usage", "SIGNING", "ENCRYPTION", "SIGNING_AND_ENCRYPTION", "TLS"));
sources.add(enumSource("edi_guide_status", "DRAFT", "ACTIVE", "RETIRED"));
sources.add(enumSource("edi_guide_source", "BUILT_IN", "IMPORTED", "CUSTOM"));
sources.add(enumSource("edi_usage", "REQUIRED", "SITUATIONAL", "NOT_USED"));
sources.add(enumSource("edi_data_type", "AN", "ID", "N0", "N1", "N2", "N3", "N4", "N5", "N6", "N7", "N8", "N9", "R", "DT", "TM", "B"));
sources.add(enumSource("edi_rule_type", "PAIRED", "REQUIRED", "EXCLUSION", "CONDITIONAL", "LIST_CONDITIONAL", "SITUATIONAL"));
sources.add(enumSource("edi_severity", "ERROR", "WARNING"));
sources.add(enumSource("edi_code_list_source", "BUILT_IN", "CUSTOM", "EXTERNAL"));
sources.add(enumSource("edi_interchange_status", "RECEIVED", "ACCEPTED", "ACCEPTED_WITH_ERRORS", "REJECTED", "DUPLICATE", "ENVELOPED", "SENT", "DELIVERED", "FAILED"));
sources.add(enumSource("edi_ack_status", "A", "E", "P", "R", "NONE", "PENDING"));
sources.add(enumSource("edi_transaction_status", "RECEIVED", "VALID", "INVALID", "MAPPED", "APPLIED", "EXCEPTION", "QUEUED", "SENT", "ACKNOWLEDGED", "REJECTED", "IGNORED"));
sources.add(enumSource("edi_event_type", "RECEIVED", "STORED", "PARSED", "VALIDATED", "ACK_SENT", "ACK_QUEUED", "MAPPED", "APPLIED", "ENVELOPED", "SENT", "MDN", "DELIVERED", "ACK_RECEIVED", "EXCEPTION", "REPROCESSED", "RESENT"));
sources.add(enumSource("edi_exception_type", "UNKNOWN_PARTNER", "PARTNER_MISMATCH", "NOT_CONFIGURED", "DUPLICATE", "ENVELOPE", "VALIDATION", "MAPPING", "APPLY", "TRANSPORT", "REJECTED_ACK", "OVERDUE_ACK", "DEAD_LETTER"));
sources.add(enumSource("edi_exception_status", "OPEN", "IN_PROGRESS", "RESOLVED", "IGNORED"));
sources.add(enumSource("edi_inbox_status", "PENDING", "PROCESSING", "PROCESSED", "RETRY_WAIT", "DEAD_LETTER", "DUPLICATE", "IGNORED"));
sources.add(enumSource("edi_as2_mdn_delivery_status", "NONE", "PENDING", "SENT", "FAILED"));
sources.add(enumSource("edi_payload_kind", "INBOUND", "OUTBOUND", "AS2", "MDN", "SAMPLE"));
sources.add(QPossibleValueSource.newForTable("edi_partner"));
sources.add(QPossibleValueSource.newForTable("edi_identity"));
sources.add(QPossibleValueSource.newForTable("edi_agreement"));
sources.add(QPossibleValueSource.newForTable("edi_agreement_document"));
sources.add(QPossibleValueSource.newForTable("edi_control_number"));
sources.add(QPossibleValueSource.newForTable("edi_transport"));
sources.add(QPossibleValueSource.newForTable("edi_certificate"));
sources.add(QPossibleValueSource.newForTable("edi_guide"));
sources.add(QPossibleValueSource.newForTable("edi_guide_segment"));
sources.add(QPossibleValueSource.newForTable("edi_guide_element"));
sources.add(QPossibleValueSource.newForTable("edi_guide_rule"));
sources.add(QPossibleValueSource.newForTable("edi_code_list"));
sources.add(QPossibleValueSource.newForTable("edi_code_list_value"));
sources.add(QPossibleValueSource.newForTable("edi_interchange"));
sources.add(QPossibleValueSource.newForTable("edi_functional_group"));
sources.add(QPossibleValueSource.newForTable("edi_transaction"));
sources.add(QPossibleValueSource.newForTable("edi_transaction_error"));
sources.add(QPossibleValueSource.newForTable("edi_event"));
sources.add(QPossibleValueSource.newForTable("edi_exception"));
sources.add(QPossibleValueSource.newForTable("edi_inbox"));
sources.add(QPossibleValueSource.newForTable("edi_payload"));
sources.add(enumSource("edi_on_match", "UPDATE", "SKIP", "EXCEPTION"));
sources.add(QPossibleValueSource.newForTable("edi_map"));
sources.add(QPossibleValueSource.newForTable("edi_map_rule"));
sources.add(QPossibleValueSource.newForTable("edi_map_sample"));
sources.add(QPossibleValueSource.newForTable("edi_outbound_request"));
sources.add(QPossibleValueSource.newForTable("edi_outbound_message"));
sources.add(QPossibleValueSource.newForTable("edi_as2_mdn"));
return (sources);
}
/*******************************************************************************
** Stable string ids for enum values.
*******************************************************************************/
private static QPossibleValueSource enumSource(String name, String... values)
{
List<QPossibleValue<?>> entries = new ArrayList<>();
for(String value : values)
{
entries.add(new QPossibleValue<>(value, value));
}
return (new QPossibleValueSource().withName(name).withType(QPossibleValueSourceType.ENUM).withEnumValues(entries));
}
/*******************************************************************************
** Foreign-key relationships used by parent record screens.
*******************************************************************************/
public static List<QJoinMetaData> joins()
{
List<QJoinMetaData> joins = new ArrayList<>();
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_identity").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_identity").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_agreement").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_agreement").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_identity_local_identity_id_edi_agreement").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_identity").withRightTable("edi_agreement").withJoinOn(new JoinOn("id", "localIdentityId")));
joins.add(new QJoinMetaData().withName("edi_identity_partner_identity_id_edi_agreement").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_identity").withRightTable("edi_agreement").withJoinOn(new JoinOn("id", "partnerIdentityId")));
joins.add(new QJoinMetaData().withName("edi_transport_inbound_transport_id_edi_agreement").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transport").withRightTable("edi_agreement").withJoinOn(new JoinOn("id", "inboundTransportId")));
joins.add(new QJoinMetaData().withName("edi_transport_outbound_transport_id_edi_agreement").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transport").withRightTable("edi_agreement").withJoinOn(new JoinOn("id", "outboundTransportId")));
joins.add(new QJoinMetaData().withName("edi_agreement_agreement_id_edi_agreement_document").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement").withRightTable("edi_agreement_document").withJoinOn(new JoinOn("id", "agreementId")));
joins.add(new QJoinMetaData().withName("edi_guide_guide_id_edi_agreement_document").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_guide").withRightTable("edi_agreement_document").withJoinOn(new JoinOn("id", "guideId")));
joins.add(new QJoinMetaData().withName("edi_agreement_agreement_id_edi_control_number").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement").withRightTable("edi_control_number").withJoinOn(new JoinOn("id", "agreementId")));
joins.add(new QJoinMetaData().withName("edi_identity_local_identity_id_edi_control_number").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_identity").withRightTable("edi_control_number").withJoinOn(new JoinOn("id", "localIdentityId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_transport").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_transport").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_certificate_local_certificate_id_edi_transport").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_certificate").withRightTable("edi_transport").withJoinOn(new JoinOn("id", "localCertificateId")));
joins.add(new QJoinMetaData().withName("edi_certificate_partner_certificate_id_edi_transport").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_certificate").withRightTable("edi_transport").withJoinOn(new JoinOn("id", "partnerCertificateId")));
joins.add(new QJoinMetaData().withName("edi_certificate_partner_tls_certificate_id_edi_transport").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_certificate").withRightTable("edi_transport").withJoinOn(new JoinOn("id", "partnerTlsCertificateId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_certificate").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_certificate").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_guide").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_guide").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_guide_guide_id_edi_guide_segment").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_guide").withRightTable("edi_guide_segment").withJoinOn(new JoinOn("id", "guideId")));
joins.add(new QJoinMetaData().withName("edi_guide_segment_guide_segment_id_edi_guide_element").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_guide_segment").withRightTable("edi_guide_element").withJoinOn(new JoinOn("id", "guideSegmentId")));
joins.add(new QJoinMetaData().withName("edi_code_list_code_list_id_edi_guide_element").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_code_list").withRightTable("edi_guide_element").withJoinOn(new JoinOn("id", "codeListId")));
joins.add(new QJoinMetaData().withName("edi_guide_guide_id_edi_guide_rule").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_guide").withRightTable("edi_guide_rule").withJoinOn(new JoinOn("id", "guideId")));
joins.add(new QJoinMetaData().withName("edi_guide_segment_guide_segment_id_edi_guide_rule").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_guide_segment").withRightTable("edi_guide_rule").withJoinOn(new JoinOn("id", "guideSegmentId")));
joins.add(new QJoinMetaData().withName("edi_code_list_code_list_id_edi_code_list_value").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_code_list").withRightTable("edi_code_list_value").withJoinOn(new JoinOn("id", "codeListId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_agreement_agreement_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "agreementId")));
joins.add(new QJoinMetaData().withName("edi_transport_transport_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transport").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "transportId")));
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "inboxId")));
joins.add(new QJoinMetaData().withName("edi_interchange_interchange_id_edi_functional_group").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_interchange").withRightTable("edi_functional_group").withJoinOn(new JoinOn("id", "interchangeId")));
joins.add(new QJoinMetaData().withName("edi_interchange_interchange_id_edi_transaction").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_interchange").withRightTable("edi_transaction").withJoinOn(new JoinOn("id", "interchangeId")));
joins.add(new QJoinMetaData().withName("edi_functional_group_functional_group_id_edi_transaction").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_functional_group").withRightTable("edi_transaction").withJoinOn(new JoinOn("id", "functionalGroupId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_transaction").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_transaction").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_agreement_document_agreement_document_id_edi_transaction").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement_document").withRightTable("edi_transaction").withJoinOn(new JoinOn("id", "agreementDocumentId")));
joins.add(new QJoinMetaData().withName("edi_guide_guide_id_edi_transaction").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_guide").withRightTable("edi_transaction").withJoinOn(new JoinOn("id", "guideId")));
joins.add(new QJoinMetaData().withName("edi_transaction_transaction_id_edi_transaction_error").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transaction").withRightTable("edi_transaction_error").withJoinOn(new JoinOn("id", "transactionId")));
joins.add(new QJoinMetaData().withName("edi_interchange_interchange_id_edi_event").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_interchange").withRightTable("edi_event").withJoinOn(new JoinOn("id", "interchangeId")));
joins.add(new QJoinMetaData().withName("edi_transaction_transaction_id_edi_event").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transaction").withRightTable("edi_event").withJoinOn(new JoinOn("id", "transactionId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_exception").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_exception").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_exception").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_exception").withJoinOn(new JoinOn("id", "inboxId")));
joins.add(new QJoinMetaData().withName("edi_interchange_interchange_id_edi_exception").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_interchange").withRightTable("edi_exception").withJoinOn(new JoinOn("id", "interchangeId")));
joins.add(new QJoinMetaData().withName("edi_transaction_transaction_id_edi_exception").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transaction").withRightTable("edi_exception").withJoinOn(new JoinOn("id", "transactionId")));
joins.add(new QJoinMetaData().withName("edi_transport_transport_id_edi_inbox").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_transport").withRightTable("edi_inbox").withJoinOn(new JoinOn("id", "transportId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_inbox").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_inbox").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_map_map_id_edi_map_rule").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_map").withRightTable("edi_map_rule").withJoinOn(new JoinOn("id", "mapId")));
joins.add(new QJoinMetaData().withName("edi_map_map_id_edi_map_sample").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_map").withRightTable("edi_map_sample").withJoinOn(new JoinOn("id", "mapId")));
joins.add(new QJoinMetaData().withName("edi_partner_partner_id_edi_map").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_partner").withRightTable("edi_map").withJoinOn(new JoinOn("id", "partnerId")));
joins.add(new QJoinMetaData().withName("edi_map_map_id_edi_agreement_document").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_map").withRightTable("edi_agreement_document").withJoinOn(new JoinOn("id", "mapId")));
joins.add(new QJoinMetaData().withName("edi_agreement_document_agreement_document_id_edi_outbound_request").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement_document").withRightTable("edi_outbound_request").withJoinOn(new JoinOn("id", "agreementDocumentId")));
joins.add(new QJoinMetaData().withName("edi_agreement_agreement_id_edi_outbound_message").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_agreement").withRightTable("edi_outbound_message").withJoinOn(new JoinOn("id", "agreementId")));
joins.add(new QJoinMetaData().withName("edi_outbound_message_outbound_message_id_edi_interchange").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_outbound_message").withRightTable("edi_interchange").withJoinOn(new JoinOn("id", "outboundMessageId")));
joins.add(new QJoinMetaData().withName("edi_outbound_message_outbound_message_id_edi_as2_mdn").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_outbound_message").withRightTable("edi_as2_mdn").withJoinOn(new JoinOn("id", "outboundMessageId")));
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_as2_mdn").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_as2_mdn").withJoinOn(new JoinOn("id", "inboxId")));
joins.add(new QJoinMetaData().withName("edi_inbox_inbox_id_edi_event").withType(JoinType.ONE_TO_MANY).withLeftTable("edi_inbox").withRightTable("edi_event").withJoinOn(new JoinOn("id", "inboxId")));
return (joins);
}
/*******************************************************************************
** Typed child lists.
*******************************************************************************/
public static List<QWidgetMetaData> childWidgets()
{
return (joins().stream().map(join -> ChildRecordListRenderer.widgetMetaDataBuilder(join).withName(join.getName())
.withLabel(joinLabel(join)).withCanAddChildRecord(false)
.withOrderBys(childOrder(join.getRightTable())).getWidgetMetaData().withGridColumns(12)).toList());
}
/*******************************************************************************
** Activity is newest first; configuration children retain their logical order.
*******************************************************************************/
private static List<QFilterOrderBy> childOrder(String table)
{
return (switch(table)
{
case "edi_transaction", "edi_interchange", "edi_exception", "edi_inbox", "edi_event", "edi_outbound_request", "edi_outbound_message", "edi_as2_mdn" ->
List.of(new QFilterOrderBy("createDate", false), new QFilterOrderBy("id", false));
case "edi_guide_segment", "edi_map_rule" -> List.of(new QFilterOrderBy("sequence"), new QFilterOrderBy("id"));
case "edi_guide_element" -> List.of(new QFilterOrderBy("position"), new QFilterOrderBy("componentPosition"), new QFilterOrderBy("id"));
default -> List.of(new QFilterOrderBy("id"));
});
}
/*******************************************************************************
** Distinguish multiple relationships using readable field labels.
*******************************************************************************/
private static String joinLabel(QJoinMetaData join)
{
return (switch(join.getJoinOns().getFirst().getRightField())
{
case "localIdentityId" -> join.getRightTable().equals("edi_agreement") ? "Local Agreements" : "Control Numbers";
case "partnerIdentityId" -> "Partner Agreements";
case "inboundTransportId" -> "Inbound Agreements";
case "outboundTransportId" -> "Outbound Agreements";
case "localCertificateId" -> "Local Certificate Transports";
case "partnerCertificateId" -> "Partner Certificate Transports";
case "partnerTlsCertificateId" -> "Partner TLS Transports";
default -> EdiTablePresentation.pluralLabel(join.getRightTable());
});
}
}
@@ -1,86 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.metadata;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import com.kingsrook.qqq.backend.core.actions.customizers.TableCustomizerInterface;
import com.kingsrook.qqq.backend.core.model.actions.AbstractActionInput;
import com.kingsrook.qqq.backend.core.model.data.QRecord;
import com.kingsrook.qqq.backend.core.model.statusmessages.BadInputStatusMessage;
/*******************************************************************************
** Validate transport routing fields, including partial record edits.
*******************************************************************************/
public class TransportValidation implements TableCustomizerInterface
{
/*******************************************************************************
** Validate merged records without exposing transport settings.
*******************************************************************************/
@Override
public List<QRecord> preInsertOrUpdate(AbstractActionInput input, List<QRecord> records, boolean preview, Optional<List<QRecord>> oldRecords)
{
for(QRecord record : records)
{
QRecord merged = new QRecord();
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
merged.getValues().putAll(record.getValues());
String name = merged.getValueString("name");
if(name == null || !name.matches("[a-z0-9-]+"))
{
record.addError(new BadInputStatusMessage("name must be a lowercase slug"));
}
String type = merged.getValueString("type");
if(type == null || type.isBlank())
{
record.addError(new BadInputStatusMessage("type is required"));
}
}
for(QRecord record : records)
{
QRecord merged = new QRecord();
oldRecords.orElse(List.of()).stream().filter(old -> Objects.equals(old.getValue("id"), record.getValue("id")))
.findFirst().ifPresent(old -> merged.getValues().putAll(old.getValues()));
merged.getValues().putAll(record.getValues());
try
{
List<String> errors = new java.util.ArrayList<>();
com.kingsrook.qbits.edi.transport.EdiTransportPluginsMetaData.current().get(merged.getValueString("type"))
.validate(merged.toEntity(com.kingsrook.qbits.edi.model.EdiTransport.class), errors);
if(merged.getValueInteger("maxAttempts") != null && merged.getValueInteger("maxAttempts") < 1)
{
errors.add("maxAttempts must be positive");
}
errors.forEach(error -> record.addError(new BadInputStatusMessage(error)));
record.setValue("weakCrypto", "AS2".equals(merged.getValueString("type")) && com.kingsrook.qbits.edi.as2.As2Crypto.isWeak(merged.getValueString("signAlgorithm"), merged.getValueString("encryptAlgorithm")));
}
catch(Exception e)
{
record.addError(new BadInputStatusMessage("Invalid transport configuration"));
}
}
return (records);
}
}
@@ -1,842 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_agreement record.
*******************************************************************************/
public class EdiAgreement extends QRecordEntity
{
public static final String TABLE_NAME = "edi_agreement";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
private Integer partnerId;
@QField(backendName = "local_identity_id", possibleValueSourceName = "edi_identity")
private Integer localIdentityId;
@QField(backendName = "partner_identity_id", possibleValueSourceName = "edi_identity")
private Integer partnerIdentityId;
@QField(backendName = "standard", maxLength = 255, possibleValueSourceName = "edi_standard")
private String standard;
@QField(backendName = "usage_indicator", maxLength = 255, possibleValueSourceName = "edi_usage_indicator")
private String usageIndicator;
@QField(backendName = "interchange_version", maxLength = 255)
private String interchangeVersion;
@QField(backendName = "element_separator", maxLength = 255, defaultValue = "*")
private String elementSeparator;
@QField(backendName = "segment_terminator", maxLength = 255, defaultValue = "~")
private String segmentTerminator;
@QField(backendName = "component_separator", maxLength = 255, defaultValue = ":")
private String componentSeparator;
@QField(backendName = "repetition_separator", maxLength = 255, defaultValue = "^")
private String repetitionSeparator;
@QField(backendName = "line_ending", maxLength = 255, possibleValueSourceName = "edi_line_ending", defaultValue = "NONE")
private String lineEnding;
@QField(backendName = "request_ta1")
private Boolean requestTa1;
@QField(backendName = "send_ta1", maxLength = 255, possibleValueSourceName = "edi_send_ta1", defaultValue = "ON_ERROR")
private String sendTa1;
@QField(backendName = "inbound_transport_id", possibleValueSourceName = "edi_transport")
private Integer inboundTransportId;
@QField(backendName = "outbound_transport_id", possibleValueSourceName = "edi_transport")
private Integer outboundTransportId;
@QField(backendName = "control_number_scope", maxLength = 255, possibleValueSourceName = "edi_control_number_scope", defaultValue = "AGREEMENT")
private String controlNumberScope;
@QField(backendName = "duplicate_window_days", defaultValue = "365")
private Integer duplicateWindowDays;
@QField(backendName = "on_duplicate", maxLength = 255, possibleValueSourceName = "edi_on_duplicate", defaultValue = "REJECT_TA1")
private String onDuplicate;
@QField(backendName = "max_interchange_bytes", defaultValue = "52428800")
private Long maxInterchangeBytes;
@QField(backendName = "apply_test_documents", defaultValue = "false")
private Boolean applyTestDocuments;
@QField(backendName = "is_active", defaultValue = "true")
private Boolean isActive;
@QField(backendName = "notes", maxLength = 10000)
private String notes;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiAgreement withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get partnerId.
*******************************************************************************/
public Integer getPartnerId()
{
return (partnerId);
}
/*******************************************************************************
** Set partnerId.
*******************************************************************************/
public void setPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
}
/*******************************************************************************
** Set partnerId fluently.
*******************************************************************************/
public EdiAgreement withPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
return (this);
}
/*******************************************************************************
** Get localIdentityId.
*******************************************************************************/
public Integer getLocalIdentityId()
{
return (localIdentityId);
}
/*******************************************************************************
** Set localIdentityId.
*******************************************************************************/
public void setLocalIdentityId(Integer localIdentityId)
{
this.localIdentityId = localIdentityId;
}
/*******************************************************************************
** Set localIdentityId fluently.
*******************************************************************************/
public EdiAgreement withLocalIdentityId(Integer localIdentityId)
{
this.localIdentityId = localIdentityId;
return (this);
}
/*******************************************************************************
** Get partnerIdentityId.
*******************************************************************************/
public Integer getPartnerIdentityId()
{
return (partnerIdentityId);
}
/*******************************************************************************
** Set partnerIdentityId.
*******************************************************************************/
public void setPartnerIdentityId(Integer partnerIdentityId)
{
this.partnerIdentityId = partnerIdentityId;
}
/*******************************************************************************
** Set partnerIdentityId fluently.
*******************************************************************************/
public EdiAgreement withPartnerIdentityId(Integer partnerIdentityId)
{
this.partnerIdentityId = partnerIdentityId;
return (this);
}
/*******************************************************************************
** Get standard.
*******************************************************************************/
public String getStandard()
{
return (standard);
}
/*******************************************************************************
** Set standard.
*******************************************************************************/
public void setStandard(String standard)
{
this.standard = standard;
}
/*******************************************************************************
** Set standard fluently.
*******************************************************************************/
public EdiAgreement withStandard(String standard)
{
this.standard = standard;
return (this);
}
/*******************************************************************************
** Get usageIndicator.
*******************************************************************************/
public String getUsageIndicator()
{
return (usageIndicator);
}
/*******************************************************************************
** Set usageIndicator.
*******************************************************************************/
public void setUsageIndicator(String usageIndicator)
{
this.usageIndicator = usageIndicator;
}
/*******************************************************************************
** Set usageIndicator fluently.
*******************************************************************************/
public EdiAgreement withUsageIndicator(String usageIndicator)
{
this.usageIndicator = usageIndicator;
return (this);
}
/*******************************************************************************
** Get interchangeVersion.
*******************************************************************************/
public String getInterchangeVersion()
{
return (interchangeVersion);
}
/*******************************************************************************
** Set interchangeVersion.
*******************************************************************************/
public void setInterchangeVersion(String interchangeVersion)
{
this.interchangeVersion = interchangeVersion;
}
/*******************************************************************************
** Set interchangeVersion fluently.
*******************************************************************************/
public EdiAgreement withInterchangeVersion(String interchangeVersion)
{
this.interchangeVersion = interchangeVersion;
return (this);
}
/*******************************************************************************
** Get elementSeparator.
*******************************************************************************/
public String getElementSeparator()
{
return (elementSeparator);
}
/*******************************************************************************
** Set elementSeparator.
*******************************************************************************/
public void setElementSeparator(String elementSeparator)
{
this.elementSeparator = elementSeparator;
}
/*******************************************************************************
** Set elementSeparator fluently.
*******************************************************************************/
public EdiAgreement withElementSeparator(String elementSeparator)
{
this.elementSeparator = elementSeparator;
return (this);
}
/*******************************************************************************
** Get segmentTerminator.
*******************************************************************************/
public String getSegmentTerminator()
{
return (segmentTerminator);
}
/*******************************************************************************
** Set segmentTerminator.
*******************************************************************************/
public void setSegmentTerminator(String segmentTerminator)
{
this.segmentTerminator = segmentTerminator;
}
/*******************************************************************************
** Set segmentTerminator fluently.
*******************************************************************************/
public EdiAgreement withSegmentTerminator(String segmentTerminator)
{
this.segmentTerminator = segmentTerminator;
return (this);
}
/*******************************************************************************
** Get componentSeparator.
*******************************************************************************/
public String getComponentSeparator()
{
return (componentSeparator);
}
/*******************************************************************************
** Set componentSeparator.
*******************************************************************************/
public void setComponentSeparator(String componentSeparator)
{
this.componentSeparator = componentSeparator;
}
/*******************************************************************************
** Set componentSeparator fluently.
*******************************************************************************/
public EdiAgreement withComponentSeparator(String componentSeparator)
{
this.componentSeparator = componentSeparator;
return (this);
}
/*******************************************************************************
** Get repetitionSeparator.
*******************************************************************************/
public String getRepetitionSeparator()
{
return (repetitionSeparator);
}
/*******************************************************************************
** Set repetitionSeparator.
*******************************************************************************/
public void setRepetitionSeparator(String repetitionSeparator)
{
this.repetitionSeparator = repetitionSeparator;
}
/*******************************************************************************
** Set repetitionSeparator fluently.
*******************************************************************************/
public EdiAgreement withRepetitionSeparator(String repetitionSeparator)
{
this.repetitionSeparator = repetitionSeparator;
return (this);
}
/*******************************************************************************
** Get lineEnding.
*******************************************************************************/
public String getLineEnding()
{
return (lineEnding);
}
/*******************************************************************************
** Set lineEnding.
*******************************************************************************/
public void setLineEnding(String lineEnding)
{
this.lineEnding = lineEnding;
}
/*******************************************************************************
** Set lineEnding fluently.
*******************************************************************************/
public EdiAgreement withLineEnding(String lineEnding)
{
this.lineEnding = lineEnding;
return (this);
}
/*******************************************************************************
** Get requestTa1.
*******************************************************************************/
public Boolean getRequestTa1()
{
return (requestTa1);
}
/*******************************************************************************
** Set requestTa1.
*******************************************************************************/
public void setRequestTa1(Boolean requestTa1)
{
this.requestTa1 = requestTa1;
}
/*******************************************************************************
** Set requestTa1 fluently.
*******************************************************************************/
public EdiAgreement withRequestTa1(Boolean requestTa1)
{
this.requestTa1 = requestTa1;
return (this);
}
/*******************************************************************************
** Get sendTa1.
*******************************************************************************/
public String getSendTa1()
{
return (sendTa1);
}
/*******************************************************************************
** Set sendTa1.
*******************************************************************************/
public void setSendTa1(String sendTa1)
{
this.sendTa1 = sendTa1;
}
/*******************************************************************************
** Set sendTa1 fluently.
*******************************************************************************/
public EdiAgreement withSendTa1(String sendTa1)
{
this.sendTa1 = sendTa1;
return (this);
}
/*******************************************************************************
** Get inboundTransportId.
*******************************************************************************/
public Integer getInboundTransportId()
{
return (inboundTransportId);
}
/*******************************************************************************
** Set inboundTransportId.
*******************************************************************************/
public void setInboundTransportId(Integer inboundTransportId)
{
this.inboundTransportId = inboundTransportId;
}
/*******************************************************************************
** Set inboundTransportId fluently.
*******************************************************************************/
public EdiAgreement withInboundTransportId(Integer inboundTransportId)
{
this.inboundTransportId = inboundTransportId;
return (this);
}
/*******************************************************************************
** Get outboundTransportId.
*******************************************************************************/
public Integer getOutboundTransportId()
{
return (outboundTransportId);
}
/*******************************************************************************
** Set outboundTransportId.
*******************************************************************************/
public void setOutboundTransportId(Integer outboundTransportId)
{
this.outboundTransportId = outboundTransportId;
}
/*******************************************************************************
** Set outboundTransportId fluently.
*******************************************************************************/
public EdiAgreement withOutboundTransportId(Integer outboundTransportId)
{
this.outboundTransportId = outboundTransportId;
return (this);
}
/*******************************************************************************
** Get controlNumberScope.
*******************************************************************************/
public String getControlNumberScope()
{
return (controlNumberScope);
}
/*******************************************************************************
** Set controlNumberScope.
*******************************************************************************/
public void setControlNumberScope(String controlNumberScope)
{
this.controlNumberScope = controlNumberScope;
}
/*******************************************************************************
** Set controlNumberScope fluently.
*******************************************************************************/
public EdiAgreement withControlNumberScope(String controlNumberScope)
{
this.controlNumberScope = controlNumberScope;
return (this);
}
/*******************************************************************************
** Get duplicateWindowDays.
*******************************************************************************/
public Integer getDuplicateWindowDays()
{
return (duplicateWindowDays);
}
/*******************************************************************************
** Set duplicateWindowDays.
*******************************************************************************/
public void setDuplicateWindowDays(Integer duplicateWindowDays)
{
this.duplicateWindowDays = duplicateWindowDays;
}
/*******************************************************************************
** Set duplicateWindowDays fluently.
*******************************************************************************/
public EdiAgreement withDuplicateWindowDays(Integer duplicateWindowDays)
{
this.duplicateWindowDays = duplicateWindowDays;
return (this);
}
/*******************************************************************************
** Get onDuplicate.
*******************************************************************************/
public String getOnDuplicate()
{
return (onDuplicate);
}
/*******************************************************************************
** Set onDuplicate.
*******************************************************************************/
public void setOnDuplicate(String onDuplicate)
{
this.onDuplicate = onDuplicate;
}
/*******************************************************************************
** Set onDuplicate fluently.
*******************************************************************************/
public EdiAgreement withOnDuplicate(String onDuplicate)
{
this.onDuplicate = onDuplicate;
return (this);
}
/*******************************************************************************
** Get maxInterchangeBytes.
*******************************************************************************/
public Long getMaxInterchangeBytes()
{
return (maxInterchangeBytes);
}
/*******************************************************************************
** Set maxInterchangeBytes.
*******************************************************************************/
public void setMaxInterchangeBytes(Long maxInterchangeBytes)
{
this.maxInterchangeBytes = maxInterchangeBytes;
}
/*******************************************************************************
** Set maxInterchangeBytes fluently.
*******************************************************************************/
public EdiAgreement withMaxInterchangeBytes(Long maxInterchangeBytes)
{
this.maxInterchangeBytes = maxInterchangeBytes;
return (this);
}
/*******************************************************************************
** Get applyTestDocuments.
*******************************************************************************/
public Boolean getApplyTestDocuments()
{
return (applyTestDocuments);
}
/*******************************************************************************
** Set applyTestDocuments.
*******************************************************************************/
public void setApplyTestDocuments(Boolean applyTestDocuments)
{
this.applyTestDocuments = applyTestDocuments;
}
/*******************************************************************************
** Set applyTestDocuments fluently.
*******************************************************************************/
public EdiAgreement withApplyTestDocuments(Boolean applyTestDocuments)
{
this.applyTestDocuments = applyTestDocuments;
return (this);
}
/*******************************************************************************
** Get isActive.
*******************************************************************************/
public Boolean getIsActive()
{
return (isActive);
}
/*******************************************************************************
** Set isActive.
*******************************************************************************/
public void setIsActive(Boolean isActive)
{
this.isActive = isActive;
}
/*******************************************************************************
** Set isActive fluently.
*******************************************************************************/
public EdiAgreement withIsActive(Boolean isActive)
{
this.isActive = isActive;
return (this);
}
/*******************************************************************************
** Get notes.
*******************************************************************************/
public String getNotes()
{
return (notes);
}
/*******************************************************************************
** Set notes.
*******************************************************************************/
public void setNotes(String notes)
{
this.notes = notes;
}
/*******************************************************************************
** Set notes fluently.
*******************************************************************************/
public EdiAgreement withNotes(String notes)
{
this.notes = notes;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiAgreement withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiAgreement withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiAgreement withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
@@ -1,749 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_agreement_document record.
*******************************************************************************/
public class EdiAgreementDocument extends QRecordEntity
{
public static final String TABLE_NAME = "edi_agreement_document";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "agreement_id", possibleValueSourceName = "edi_agreement")
private Integer agreementId;
@QField(backendName = "direction", maxLength = 255, possibleValueSourceName = "edi_direction")
private String direction;
@QField(backendName = "transaction_set", maxLength = 255)
private String transactionSet;
@QField(backendName = "functional_id_code", maxLength = 255)
private String functionalIdCode;
@QField(backendName = "version", maxLength = 255)
private String version;
@QField(backendName = "guide_id", possibleValueSourceName = "edi_guide")
private Integer guideId;
@QField(backendName = "map_id", possibleValueSourceName = "edi_map")
private Integer mapId;
@QField(backendName = "ack_expectation", maxLength = 255, possibleValueSourceName = "edi_ack_expectation")
private String ackExpectation;
@QField(backendName = "ack_due_minutes", defaultValue = "1440")
private Integer ackDueMinutes;
@QField(backendName = "ack_level", maxLength = 255, possibleValueSourceName = "edi_ack_level")
private String ackLevel;
@QField(backendName = "snip_level")
private Integer snipLevel;
@QField(backendName = "contains_phi")
private Boolean containsPhi;
@QField(backendName = "target_table_name", maxLength = 255)
private String targetTableName;
@QField(backendName = "source_table_name", maxLength = 255)
private String sourceTableName;
@QField(backendName = "outbound_trigger", maxLength = 255, possibleValueSourceName = "edi_outbound_trigger")
private String outboundTrigger;
@QField(backendName = "trigger_filter", maxLength = 10000)
private String triggerFilter;
@QField(backendName = "batch_mode", maxLength = 255, possibleValueSourceName = "edi_batch_mode")
private String batchMode;
@QField(backendName = "batch_schedule", maxLength = 255)
private String batchSchedule;
@QField(backendName = "is_active", defaultValue = "true")
private Boolean isActive;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiAgreementDocument withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get agreementId.
*******************************************************************************/
public Integer getAgreementId()
{
return (agreementId);
}
/*******************************************************************************
** Set agreementId.
*******************************************************************************/
public void setAgreementId(Integer agreementId)
{
this.agreementId = agreementId;
}
/*******************************************************************************
** Set agreementId fluently.
*******************************************************************************/
public EdiAgreementDocument withAgreementId(Integer agreementId)
{
this.agreementId = agreementId;
return (this);
}
/*******************************************************************************
** Get direction.
*******************************************************************************/
public String getDirection()
{
return (direction);
}
/*******************************************************************************
** Set direction.
*******************************************************************************/
public void setDirection(String direction)
{
this.direction = direction;
}
/*******************************************************************************
** Set direction fluently.
*******************************************************************************/
public EdiAgreementDocument withDirection(String direction)
{
this.direction = direction;
return (this);
}
/*******************************************************************************
** Get transactionSet.
*******************************************************************************/
public String getTransactionSet()
{
return (transactionSet);
}
/*******************************************************************************
** Set transactionSet.
*******************************************************************************/
public void setTransactionSet(String transactionSet)
{
this.transactionSet = transactionSet;
}
/*******************************************************************************
** Set transactionSet fluently.
*******************************************************************************/
public EdiAgreementDocument withTransactionSet(String transactionSet)
{
this.transactionSet = transactionSet;
return (this);
}
/*******************************************************************************
** Get functionalIdCode.
*******************************************************************************/
public String getFunctionalIdCode()
{
return (functionalIdCode);
}
/*******************************************************************************
** Set functionalIdCode.
*******************************************************************************/
public void setFunctionalIdCode(String functionalIdCode)
{
this.functionalIdCode = functionalIdCode;
}
/*******************************************************************************
** Set functionalIdCode fluently.
*******************************************************************************/
public EdiAgreementDocument withFunctionalIdCode(String functionalIdCode)
{
this.functionalIdCode = functionalIdCode;
return (this);
}
/*******************************************************************************
** Get version.
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Set version.
*******************************************************************************/
public void setVersion(String version)
{
this.version = version;
}
/*******************************************************************************
** Set version fluently.
*******************************************************************************/
public EdiAgreementDocument withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Get guideId.
*******************************************************************************/
public Integer getGuideId()
{
return (guideId);
}
/*******************************************************************************
** Set guideId.
*******************************************************************************/
public void setGuideId(Integer guideId)
{
this.guideId = guideId;
}
/*******************************************************************************
** Set guideId fluently.
*******************************************************************************/
public EdiAgreementDocument withGuideId(Integer guideId)
{
this.guideId = guideId;
return (this);
}
/*******************************************************************************
** Get mapId.
*******************************************************************************/
public Integer getMapId()
{
return (mapId);
}
/*******************************************************************************
** Set mapId.
*******************************************************************************/
public void setMapId(Integer mapId)
{
this.mapId = mapId;
}
/*******************************************************************************
** Set mapId fluently.
*******************************************************************************/
public EdiAgreementDocument withMapId(Integer mapId)
{
this.mapId = mapId;
return (this);
}
/*******************************************************************************
** Get ackExpectation.
*******************************************************************************/
public String getAckExpectation()
{
return (ackExpectation);
}
/*******************************************************************************
** Set ackExpectation.
*******************************************************************************/
public void setAckExpectation(String ackExpectation)
{
this.ackExpectation = ackExpectation;
}
/*******************************************************************************
** Set ackExpectation fluently.
*******************************************************************************/
public EdiAgreementDocument withAckExpectation(String ackExpectation)
{
this.ackExpectation = ackExpectation;
return (this);
}
/*******************************************************************************
** Get ackDueMinutes.
*******************************************************************************/
public Integer getAckDueMinutes()
{
return (ackDueMinutes);
}
/*******************************************************************************
** Set ackDueMinutes.
*******************************************************************************/
public void setAckDueMinutes(Integer ackDueMinutes)
{
this.ackDueMinutes = ackDueMinutes;
}
/*******************************************************************************
** Set ackDueMinutes fluently.
*******************************************************************************/
public EdiAgreementDocument withAckDueMinutes(Integer ackDueMinutes)
{
this.ackDueMinutes = ackDueMinutes;
return (this);
}
/*******************************************************************************
** Get ackLevel.
*******************************************************************************/
public String getAckLevel()
{
return (ackLevel);
}
/*******************************************************************************
** Set ackLevel.
*******************************************************************************/
public void setAckLevel(String ackLevel)
{
this.ackLevel = ackLevel;
}
/*******************************************************************************
** Set ackLevel fluently.
*******************************************************************************/
public EdiAgreementDocument withAckLevel(String ackLevel)
{
this.ackLevel = ackLevel;
return (this);
}
/*******************************************************************************
** Get snipLevel.
*******************************************************************************/
public Integer getSnipLevel()
{
return (snipLevel);
}
/*******************************************************************************
** Set snipLevel.
*******************************************************************************/
public void setSnipLevel(Integer snipLevel)
{
this.snipLevel = snipLevel;
}
/*******************************************************************************
** Set snipLevel fluently.
*******************************************************************************/
public EdiAgreementDocument withSnipLevel(Integer snipLevel)
{
this.snipLevel = snipLevel;
return (this);
}
/*******************************************************************************
** Get containsPhi.
*******************************************************************************/
public Boolean getContainsPhi()
{
return (containsPhi);
}
/*******************************************************************************
** Set containsPhi.
*******************************************************************************/
public void setContainsPhi(Boolean containsPhi)
{
this.containsPhi = containsPhi;
}
/*******************************************************************************
** Set containsPhi fluently.
*******************************************************************************/
public EdiAgreementDocument withContainsPhi(Boolean containsPhi)
{
this.containsPhi = containsPhi;
return (this);
}
/*******************************************************************************
** Get targetTableName.
*******************************************************************************/
public String getTargetTableName()
{
return (targetTableName);
}
/*******************************************************************************
** Set targetTableName.
*******************************************************************************/
public void setTargetTableName(String targetTableName)
{
this.targetTableName = targetTableName;
}
/*******************************************************************************
** Set targetTableName fluently.
*******************************************************************************/
public EdiAgreementDocument withTargetTableName(String targetTableName)
{
this.targetTableName = targetTableName;
return (this);
}
/*******************************************************************************
** Get sourceTableName.
*******************************************************************************/
public String getSourceTableName()
{
return (sourceTableName);
}
/*******************************************************************************
** Set sourceTableName.
*******************************************************************************/
public void setSourceTableName(String sourceTableName)
{
this.sourceTableName = sourceTableName;
}
/*******************************************************************************
** Set sourceTableName fluently.
*******************************************************************************/
public EdiAgreementDocument withSourceTableName(String sourceTableName)
{
this.sourceTableName = sourceTableName;
return (this);
}
/*******************************************************************************
** Get outboundTrigger.
*******************************************************************************/
public String getOutboundTrigger()
{
return (outboundTrigger);
}
/*******************************************************************************
** Set outboundTrigger.
*******************************************************************************/
public void setOutboundTrigger(String outboundTrigger)
{
this.outboundTrigger = outboundTrigger;
}
/*******************************************************************************
** Set outboundTrigger fluently.
*******************************************************************************/
public EdiAgreementDocument withOutboundTrigger(String outboundTrigger)
{
this.outboundTrigger = outboundTrigger;
return (this);
}
/*******************************************************************************
** Get triggerFilter.
*******************************************************************************/
public String getTriggerFilter()
{
return (triggerFilter);
}
/*******************************************************************************
** Set triggerFilter.
*******************************************************************************/
public void setTriggerFilter(String triggerFilter)
{
this.triggerFilter = triggerFilter;
}
/*******************************************************************************
** Set triggerFilter fluently.
*******************************************************************************/
public EdiAgreementDocument withTriggerFilter(String triggerFilter)
{
this.triggerFilter = triggerFilter;
return (this);
}
/*******************************************************************************
** Get batchMode.
*******************************************************************************/
public String getBatchMode()
{
return (batchMode);
}
/*******************************************************************************
** Set batchMode.
*******************************************************************************/
public void setBatchMode(String batchMode)
{
this.batchMode = batchMode;
}
/*******************************************************************************
** Set batchMode fluently.
*******************************************************************************/
public EdiAgreementDocument withBatchMode(String batchMode)
{
this.batchMode = batchMode;
return (this);
}
/*******************************************************************************
** Get batchSchedule.
*******************************************************************************/
public String getBatchSchedule()
{
return (batchSchedule);
}
/*******************************************************************************
** Set batchSchedule.
*******************************************************************************/
public void setBatchSchedule(String batchSchedule)
{
this.batchSchedule = batchSchedule;
}
/*******************************************************************************
** Set batchSchedule fluently.
*******************************************************************************/
public EdiAgreementDocument withBatchSchedule(String batchSchedule)
{
this.batchSchedule = batchSchedule;
return (this);
}
/*******************************************************************************
** Get isActive.
*******************************************************************************/
public Boolean getIsActive()
{
return (isActive);
}
/*******************************************************************************
** Set isActive.
*******************************************************************************/
public void setIsActive(Boolean isActive)
{
this.isActive = isActive;
}
/*******************************************************************************
** Set isActive fluently.
*******************************************************************************/
public EdiAgreementDocument withIsActive(Boolean isActive)
{
this.isActive = isActive;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiAgreementDocument withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiAgreementDocument withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiAgreementDocument withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
@@ -1,718 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_as2_mdn record: an AS2 receipt we sent or received. Rows are
** written by the qbit only; the delivery columns queue asynchronous receipts.
*******************************************************************************/
public class EdiAs2Mdn extends QRecordEntity
{
public static final String TABLE_NAME = "edi_as2_mdn";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "direction", maxLength = 20, possibleValueSourceName = "edi_direction")
private String direction;
@QField(backendName = "message_id", maxLength = 255)
private String messageId;
@QField(backendName = "original_message_id", maxLength = 255)
private String originalMessageId;
@QField(backendName = "disposition", maxLength = 255)
private String disposition;
@QField(backendName = "mic_algorithm", maxLength = 50)
private String micAlgorithm;
@QField(backendName = "mic_value", maxLength = 255)
private String micValue;
@QField(backendName = "signed")
private Boolean signed;
@QField(backendName = "signature_valid")
private Boolean signatureValid;
@QField(backendName = "payload_ref", maxLength = 255)
private String payloadRef;
@QField(backendName = "received_at")
private Instant receivedAt;
@QField(backendName = "transport_id", possibleValueSourceName = "edi_transport")
private Integer transportId;
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
private Integer partnerId;
@QField(backendName = "outbound_message_id", possibleValueSourceName = "edi_outbound_message")
private Integer outboundMessageId;
@QField(backendName = "inbox_id", possibleValueSourceName = "edi_inbox")
private Integer inboxId;
@QField(backendName = "delivery_url", maxLength = 1000)
private String deliveryUrl;
@QField(backendName = "delivery_status", maxLength = 20, possibleValueSourceName = "edi_as2_mdn_delivery_status")
private String deliveryStatus;
@QField(backendName = "delivery_attempts")
private Integer deliveryAttempts;
@QField(backendName = "next_attempt_at")
private Instant nextAttemptAt;
@QField(backendName = "delivered_at")
private Instant deliveredAt;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiAs2Mdn withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get direction.
*******************************************************************************/
public String getDirection()
{
return (direction);
}
/*******************************************************************************
** Set direction.
*******************************************************************************/
public void setDirection(String direction)
{
this.direction = direction;
}
/*******************************************************************************
** Set direction fluently.
*******************************************************************************/
public EdiAs2Mdn withDirection(String direction)
{
this.direction = direction;
return (this);
}
/*******************************************************************************
** Get messageId.
*******************************************************************************/
public String getMessageId()
{
return (messageId);
}
/*******************************************************************************
** Set messageId.
*******************************************************************************/
public void setMessageId(String messageId)
{
this.messageId = messageId;
}
/*******************************************************************************
** Set messageId fluently.
*******************************************************************************/
public EdiAs2Mdn withMessageId(String messageId)
{
this.messageId = messageId;
return (this);
}
/*******************************************************************************
** Get originalMessageId.
*******************************************************************************/
public String getOriginalMessageId()
{
return (originalMessageId);
}
/*******************************************************************************
** Set originalMessageId.
*******************************************************************************/
public void setOriginalMessageId(String originalMessageId)
{
this.originalMessageId = originalMessageId;
}
/*******************************************************************************
** Set originalMessageId fluently.
*******************************************************************************/
public EdiAs2Mdn withOriginalMessageId(String originalMessageId)
{
this.originalMessageId = originalMessageId;
return (this);
}
/*******************************************************************************
** Get disposition.
*******************************************************************************/
public String getDisposition()
{
return (disposition);
}
/*******************************************************************************
** Set disposition.
*******************************************************************************/
public void setDisposition(String disposition)
{
this.disposition = disposition;
}
/*******************************************************************************
** Set disposition fluently.
*******************************************************************************/
public EdiAs2Mdn withDisposition(String disposition)
{
this.disposition = disposition;
return (this);
}
/*******************************************************************************
** Get micAlgorithm.
*******************************************************************************/
public String getMicAlgorithm()
{
return (micAlgorithm);
}
/*******************************************************************************
** Set micAlgorithm.
*******************************************************************************/
public void setMicAlgorithm(String micAlgorithm)
{
this.micAlgorithm = micAlgorithm;
}
/*******************************************************************************
** Set micAlgorithm fluently.
*******************************************************************************/
public EdiAs2Mdn withMicAlgorithm(String micAlgorithm)
{
this.micAlgorithm = micAlgorithm;
return (this);
}
/*******************************************************************************
** Get micValue.
*******************************************************************************/
public String getMicValue()
{
return (micValue);
}
/*******************************************************************************
** Set micValue.
*******************************************************************************/
public void setMicValue(String micValue)
{
this.micValue = micValue;
}
/*******************************************************************************
** Set micValue fluently.
*******************************************************************************/
public EdiAs2Mdn withMicValue(String micValue)
{
this.micValue = micValue;
return (this);
}
/*******************************************************************************
** Get signed.
*******************************************************************************/
public Boolean getSigned()
{
return (signed);
}
/*******************************************************************************
** Set signed.
*******************************************************************************/
public void setSigned(Boolean signed)
{
this.signed = signed;
}
/*******************************************************************************
** Set signed fluently.
*******************************************************************************/
public EdiAs2Mdn withSigned(Boolean signed)
{
this.signed = signed;
return (this);
}
/*******************************************************************************
** Get signatureValid.
*******************************************************************************/
public Boolean getSignatureValid()
{
return (signatureValid);
}
/*******************************************************************************
** Set signatureValid.
*******************************************************************************/
public void setSignatureValid(Boolean signatureValid)
{
this.signatureValid = signatureValid;
}
/*******************************************************************************
** Set signatureValid fluently.
*******************************************************************************/
public EdiAs2Mdn withSignatureValid(Boolean signatureValid)
{
this.signatureValid = signatureValid;
return (this);
}
/*******************************************************************************
** Get payloadRef.
*******************************************************************************/
public String getPayloadRef()
{
return (payloadRef);
}
/*******************************************************************************
** Set payloadRef.
*******************************************************************************/
public void setPayloadRef(String payloadRef)
{
this.payloadRef = payloadRef;
}
/*******************************************************************************
** Set payloadRef fluently.
*******************************************************************************/
public EdiAs2Mdn withPayloadRef(String payloadRef)
{
this.payloadRef = payloadRef;
return (this);
}
/*******************************************************************************
** Get receivedAt.
*******************************************************************************/
public Instant getReceivedAt()
{
return (receivedAt);
}
/*******************************************************************************
** Set receivedAt.
*******************************************************************************/
public void setReceivedAt(Instant receivedAt)
{
this.receivedAt = receivedAt;
}
/*******************************************************************************
** Set receivedAt fluently.
*******************************************************************************/
public EdiAs2Mdn withReceivedAt(Instant receivedAt)
{
this.receivedAt = receivedAt;
return (this);
}
/*******************************************************************************
** Get transportId.
*******************************************************************************/
public Integer getTransportId()
{
return (transportId);
}
/*******************************************************************************
** Set transportId.
*******************************************************************************/
public void setTransportId(Integer transportId)
{
this.transportId = transportId;
}
/*******************************************************************************
** Set transportId fluently.
*******************************************************************************/
public EdiAs2Mdn withTransportId(Integer transportId)
{
this.transportId = transportId;
return (this);
}
/*******************************************************************************
** Get partnerId.
*******************************************************************************/
public Integer getPartnerId()
{
return (partnerId);
}
/*******************************************************************************
** Set partnerId.
*******************************************************************************/
public void setPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
}
/*******************************************************************************
** Set partnerId fluently.
*******************************************************************************/
public EdiAs2Mdn withPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
return (this);
}
/*******************************************************************************
** Get outboundMessageId.
*******************************************************************************/
public Integer getOutboundMessageId()
{
return (outboundMessageId);
}
/*******************************************************************************
** Set outboundMessageId.
*******************************************************************************/
public void setOutboundMessageId(Integer outboundMessageId)
{
this.outboundMessageId = outboundMessageId;
}
/*******************************************************************************
** Set outboundMessageId fluently.
*******************************************************************************/
public EdiAs2Mdn withOutboundMessageId(Integer outboundMessageId)
{
this.outboundMessageId = outboundMessageId;
return (this);
}
/*******************************************************************************
** Get inboxId.
*******************************************************************************/
public Integer getInboxId()
{
return (inboxId);
}
/*******************************************************************************
** Set inboxId.
*******************************************************************************/
public void setInboxId(Integer inboxId)
{
this.inboxId = inboxId;
}
/*******************************************************************************
** Set inboxId fluently.
*******************************************************************************/
public EdiAs2Mdn withInboxId(Integer inboxId)
{
this.inboxId = inboxId;
return (this);
}
/*******************************************************************************
** Get deliveryUrl.
*******************************************************************************/
public String getDeliveryUrl()
{
return (deliveryUrl);
}
/*******************************************************************************
** Set deliveryUrl.
*******************************************************************************/
public void setDeliveryUrl(String deliveryUrl)
{
this.deliveryUrl = deliveryUrl;
}
/*******************************************************************************
** Set deliveryUrl fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveryUrl(String deliveryUrl)
{
this.deliveryUrl = deliveryUrl;
return (this);
}
/*******************************************************************************
** Get deliveryStatus.
*******************************************************************************/
public String getDeliveryStatus()
{
return (deliveryStatus);
}
/*******************************************************************************
** Set deliveryStatus.
*******************************************************************************/
public void setDeliveryStatus(String deliveryStatus)
{
this.deliveryStatus = deliveryStatus;
}
/*******************************************************************************
** Set deliveryStatus fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveryStatus(String deliveryStatus)
{
this.deliveryStatus = deliveryStatus;
return (this);
}
/*******************************************************************************
** Get deliveryAttempts.
*******************************************************************************/
public Integer getDeliveryAttempts()
{
return (deliveryAttempts);
}
/*******************************************************************************
** Set deliveryAttempts.
*******************************************************************************/
public void setDeliveryAttempts(Integer deliveryAttempts)
{
this.deliveryAttempts = deliveryAttempts;
}
/*******************************************************************************
** Set deliveryAttempts fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveryAttempts(Integer deliveryAttempts)
{
this.deliveryAttempts = deliveryAttempts;
return (this);
}
/*******************************************************************************
** Get nextAttemptAt.
*******************************************************************************/
public Instant getNextAttemptAt()
{
return (nextAttemptAt);
}
/*******************************************************************************
** Set nextAttemptAt.
*******************************************************************************/
public void setNextAttemptAt(Instant nextAttemptAt)
{
this.nextAttemptAt = nextAttemptAt;
}
/*******************************************************************************
** Set nextAttemptAt fluently.
*******************************************************************************/
public EdiAs2Mdn withNextAttemptAt(Instant nextAttemptAt)
{
this.nextAttemptAt = nextAttemptAt;
return (this);
}
/*******************************************************************************
** Get deliveredAt.
*******************************************************************************/
public Instant getDeliveredAt()
{
return (deliveredAt);
}
/*******************************************************************************
** Set deliveredAt.
*******************************************************************************/
public void setDeliveredAt(Instant deliveredAt)
{
this.deliveredAt = deliveredAt;
}
/*******************************************************************************
** Set deliveredAt fluently.
*******************************************************************************/
public EdiAs2Mdn withDeliveredAt(Instant deliveredAt)
{
this.deliveredAt = deliveredAt;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiAs2Mdn withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiAs2Mdn withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
}
@@ -1,560 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_certificate record.
*******************************************************************************/
public class EdiCertificate extends QRecordEntity
{
public static final String TABLE_NAME = "edi_certificate";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "alias", maxLength = 255)
private String alias;
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
private Integer partnerId;
@QField(backendName = "usage", maxLength = 255, possibleValueSourceName = "edi_certificate_usage")
private String usage;
@QField(backendName = "certificate_pem", maxLength = 10000)
private String certificatePem;
@QField(backendName = "subject", maxLength = 255)
private String subject;
@QField(backendName = "issuer", maxLength = 255)
private String issuer;
@QField(backendName = "serial_number", maxLength = 255)
private String serialNumber;
@QField(backendName = "fingerprint_sha256", maxLength = 255)
private String fingerprintSha256;
@QField(backendName = "not_before")
private Instant notBefore;
@QField(backendName = "not_after")
private Instant notAfter;
@QField(backendName = "private_key_env_var", maxLength = 255)
private String privateKeyEnvVar;
@QField(backendName = "private_key_password_env_var", maxLength = 255)
private String privateKeyPasswordEnvVar;
@QField(backendName = "status", maxLength = 20, possibleValueSourceName = "edi_certificate_status")
private String status;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiCertificate withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get alias.
*******************************************************************************/
public String getAlias()
{
return (alias);
}
/*******************************************************************************
** Set alias.
*******************************************************************************/
public void setAlias(String alias)
{
this.alias = alias;
}
/*******************************************************************************
** Set alias fluently.
*******************************************************************************/
public EdiCertificate withAlias(String alias)
{
this.alias = alias;
return (this);
}
/*******************************************************************************
** Get partnerId.
*******************************************************************************/
public Integer getPartnerId()
{
return (partnerId);
}
/*******************************************************************************
** Set partnerId.
*******************************************************************************/
public void setPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
}
/*******************************************************************************
** Set partnerId fluently.
*******************************************************************************/
public EdiCertificate withPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
return (this);
}
/*******************************************************************************
** Get usage.
*******************************************************************************/
public String getUsage()
{
return (usage);
}
/*******************************************************************************
** Set usage.
*******************************************************************************/
public void setUsage(String usage)
{
this.usage = usage;
}
/*******************************************************************************
** Set usage fluently.
*******************************************************************************/
public EdiCertificate withUsage(String usage)
{
this.usage = usage;
return (this);
}
/*******************************************************************************
** Get certificatePem.
*******************************************************************************/
public String getCertificatePem()
{
return (certificatePem);
}
/*******************************************************************************
** Set certificatePem.
*******************************************************************************/
public void setCertificatePem(String certificatePem)
{
this.certificatePem = certificatePem;
}
/*******************************************************************************
** Set certificatePem fluently.
*******************************************************************************/
public EdiCertificate withCertificatePem(String certificatePem)
{
this.certificatePem = certificatePem;
return (this);
}
/*******************************************************************************
** Get subject.
*******************************************************************************/
public String getSubject()
{
return (subject);
}
/*******************************************************************************
** Set subject.
*******************************************************************************/
public void setSubject(String subject)
{
this.subject = subject;
}
/*******************************************************************************
** Set subject fluently.
*******************************************************************************/
public EdiCertificate withSubject(String subject)
{
this.subject = subject;
return (this);
}
/*******************************************************************************
** Get issuer.
*******************************************************************************/
public String getIssuer()
{
return (issuer);
}
/*******************************************************************************
** Set issuer.
*******************************************************************************/
public void setIssuer(String issuer)
{
this.issuer = issuer;
}
/*******************************************************************************
** Set issuer fluently.
*******************************************************************************/
public EdiCertificate withIssuer(String issuer)
{
this.issuer = issuer;
return (this);
}
/*******************************************************************************
** Get serialNumber.
*******************************************************************************/
public String getSerialNumber()
{
return (serialNumber);
}
/*******************************************************************************
** Set serialNumber.
*******************************************************************************/
public void setSerialNumber(String serialNumber)
{
this.serialNumber = serialNumber;
}
/*******************************************************************************
** Set serialNumber fluently.
*******************************************************************************/
public EdiCertificate withSerialNumber(String serialNumber)
{
this.serialNumber = serialNumber;
return (this);
}
/*******************************************************************************
** Get fingerprintSha256.
*******************************************************************************/
public String getFingerprintSha256()
{
return (fingerprintSha256);
}
/*******************************************************************************
** Set fingerprintSha256.
*******************************************************************************/
public void setFingerprintSha256(String fingerprintSha256)
{
this.fingerprintSha256 = fingerprintSha256;
}
/*******************************************************************************
** Set fingerprintSha256 fluently.
*******************************************************************************/
public EdiCertificate withFingerprintSha256(String fingerprintSha256)
{
this.fingerprintSha256 = fingerprintSha256;
return (this);
}
/*******************************************************************************
** Get notBefore.
*******************************************************************************/
public Instant getNotBefore()
{
return (notBefore);
}
/*******************************************************************************
** Set notBefore.
*******************************************************************************/
public void setNotBefore(Instant notBefore)
{
this.notBefore = notBefore;
}
/*******************************************************************************
** Set notBefore fluently.
*******************************************************************************/
public EdiCertificate withNotBefore(Instant notBefore)
{
this.notBefore = notBefore;
return (this);
}
/*******************************************************************************
** Get notAfter.
*******************************************************************************/
public Instant getNotAfter()
{
return (notAfter);
}
/*******************************************************************************
** Set notAfter.
*******************************************************************************/
public void setNotAfter(Instant notAfter)
{
this.notAfter = notAfter;
}
/*******************************************************************************
** Set notAfter fluently.
*******************************************************************************/
public EdiCertificate withNotAfter(Instant notAfter)
{
this.notAfter = notAfter;
return (this);
}
/*******************************************************************************
** Get privateKeyEnvVar.
*******************************************************************************/
public String getPrivateKeyEnvVar()
{
return (privateKeyEnvVar);
}
/*******************************************************************************
** Set privateKeyEnvVar.
*******************************************************************************/
public void setPrivateKeyEnvVar(String privateKeyEnvVar)
{
this.privateKeyEnvVar = privateKeyEnvVar;
}
/*******************************************************************************
** Set privateKeyEnvVar fluently.
*******************************************************************************/
public EdiCertificate withPrivateKeyEnvVar(String privateKeyEnvVar)
{
this.privateKeyEnvVar = privateKeyEnvVar;
return (this);
}
/*******************************************************************************
** Get privateKeyPasswordEnvVar.
*******************************************************************************/
public String getPrivateKeyPasswordEnvVar()
{
return (privateKeyPasswordEnvVar);
}
/*******************************************************************************
** Set privateKeyPasswordEnvVar.
*******************************************************************************/
public void setPrivateKeyPasswordEnvVar(String privateKeyPasswordEnvVar)
{
this.privateKeyPasswordEnvVar = privateKeyPasswordEnvVar;
}
/*******************************************************************************
** Set privateKeyPasswordEnvVar fluently.
*******************************************************************************/
public EdiCertificate withPrivateKeyPasswordEnvVar(String privateKeyPasswordEnvVar)
{
this.privateKeyPasswordEnvVar = privateKeyPasswordEnvVar;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiCertificate withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiCertificate withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiCertificate withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
/*******************************************************************************
** Get certificate rollover status.
*******************************************************************************/
public String getStatus()
{
return (status);
}
/*******************************************************************************
** Set certificate rollover status.
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Set certificate rollover status fluently.
*******************************************************************************/
public EdiCertificate withStatus(String status)
{
this.status = status;
return (this);
}
}
@@ -1,284 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_code_list record.
*******************************************************************************/
public class EdiCodeList extends QRecordEntity
{
public static final String TABLE_NAME = "edi_code_list";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "name", maxLength = 255)
private String name;
@QField(backendName = "element_id", maxLength = 255)
private String elementId;
@QField(backendName = "source", maxLength = 255, possibleValueSourceName = "edi_code_list_source")
private String source;
@QField(backendName = "external_code_set", maxLength = 255)
private String externalCodeSet;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiCodeList withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Set name.
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** Set name fluently.
*******************************************************************************/
public EdiCodeList withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Get elementId.
*******************************************************************************/
public String getElementId()
{
return (elementId);
}
/*******************************************************************************
** Set elementId.
*******************************************************************************/
public void setElementId(String elementId)
{
this.elementId = elementId;
}
/*******************************************************************************
** Set elementId fluently.
*******************************************************************************/
public EdiCodeList withElementId(String elementId)
{
this.elementId = elementId;
return (this);
}
/*******************************************************************************
** Get source.
*******************************************************************************/
public String getSource()
{
return (source);
}
/*******************************************************************************
** Set source.
*******************************************************************************/
public void setSource(String source)
{
this.source = source;
}
/*******************************************************************************
** Set source fluently.
*******************************************************************************/
public EdiCodeList withSource(String source)
{
this.source = source;
return (this);
}
/*******************************************************************************
** Get externalCodeSet.
*******************************************************************************/
public String getExternalCodeSet()
{
return (externalCodeSet);
}
/*******************************************************************************
** Set externalCodeSet.
*******************************************************************************/
public void setExternalCodeSet(String externalCodeSet)
{
this.externalCodeSet = externalCodeSet;
}
/*******************************************************************************
** Set externalCodeSet fluently.
*******************************************************************************/
public EdiCodeList withExternalCodeSet(String externalCodeSet)
{
this.externalCodeSet = externalCodeSet;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiCodeList withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiCodeList withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiCodeList withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
@@ -1,284 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_code_list_value record.
*******************************************************************************/
public class EdiCodeListValue extends QRecordEntity
{
public static final String TABLE_NAME = "edi_code_list_value";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "code_list_id", possibleValueSourceName = "edi_code_list")
private Integer codeListId;
@QField(backendName = "code", maxLength = 255)
private String code;
@QField(backendName = "description", maxLength = 10000)
private String description;
@QField(backendName = "is_active", defaultValue = "true")
private Boolean isActive;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiCodeListValue withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get codeListId.
*******************************************************************************/
public Integer getCodeListId()
{
return (codeListId);
}
/*******************************************************************************
** Set codeListId.
*******************************************************************************/
public void setCodeListId(Integer codeListId)
{
this.codeListId = codeListId;
}
/*******************************************************************************
** Set codeListId fluently.
*******************************************************************************/
public EdiCodeListValue withCodeListId(Integer codeListId)
{
this.codeListId = codeListId;
return (this);
}
/*******************************************************************************
** Get code.
*******************************************************************************/
public String getCode()
{
return (code);
}
/*******************************************************************************
** Set code.
*******************************************************************************/
public void setCode(String code)
{
this.code = code;
}
/*******************************************************************************
** Set code fluently.
*******************************************************************************/
public EdiCodeListValue withCode(String code)
{
this.code = code;
return (this);
}
/*******************************************************************************
** Get description.
*******************************************************************************/
public String getDescription()
{
return (description);
}
/*******************************************************************************
** Set description.
*******************************************************************************/
public void setDescription(String description)
{
this.description = description;
}
/*******************************************************************************
** Set description fluently.
*******************************************************************************/
public EdiCodeListValue withDescription(String description)
{
this.description = description;
return (this);
}
/*******************************************************************************
** Get isActive.
*******************************************************************************/
public Boolean getIsActive()
{
return (isActive);
}
/*******************************************************************************
** Set isActive.
*******************************************************************************/
public void setIsActive(Boolean isActive)
{
this.isActive = isActive;
}
/*******************************************************************************
** Set isActive fluently.
*******************************************************************************/
public EdiCodeListValue withIsActive(Boolean isActive)
{
this.isActive = isActive;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiCodeListValue withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiCodeListValue withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiCodeListValue withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
@@ -1,408 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_control_number record.
*******************************************************************************/
public class EdiControlNumber extends QRecordEntity
{
public static final String TABLE_NAME = "edi_control_number";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "agreement_id", possibleValueSourceName = "edi_agreement")
private Integer agreementId;
@QField(backendName = "local_identity_id", possibleValueSourceName = "edi_identity")
private Integer localIdentityId;
@QField(backendName = "scope_key", maxLength = 255)
private String scopeKey;
@QField(backendName = "level", maxLength = 255, possibleValueSourceName = "edi_level")
private String level;
@QField(backendName = "last_value", defaultValue = "0")
private Long lastValue;
@QField(backendName = "min_value", defaultValue = "1")
private Long minValue;
@QField(backendName = "max_value", defaultValue = "999999999")
private Long maxValue;
@QField(backendName = "wrap_allowed", defaultValue = "true")
private Boolean wrapAllowed;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiControlNumber withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get agreementId.
*******************************************************************************/
public Integer getAgreementId()
{
return (agreementId);
}
/*******************************************************************************
** Set agreementId.
*******************************************************************************/
public void setAgreementId(Integer agreementId)
{
this.agreementId = agreementId;
}
/*******************************************************************************
** Set agreementId fluently.
*******************************************************************************/
public EdiControlNumber withAgreementId(Integer agreementId)
{
this.agreementId = agreementId;
return (this);
}
/*******************************************************************************
** Get localIdentityId.
*******************************************************************************/
public Integer getLocalIdentityId()
{
return (localIdentityId);
}
/*******************************************************************************
** Set localIdentityId.
*******************************************************************************/
public void setLocalIdentityId(Integer localIdentityId)
{
this.localIdentityId = localIdentityId;
}
/*******************************************************************************
** Set localIdentityId fluently.
*******************************************************************************/
public EdiControlNumber withLocalIdentityId(Integer localIdentityId)
{
this.localIdentityId = localIdentityId;
return (this);
}
/*******************************************************************************
** Get scopeKey.
*******************************************************************************/
public String getScopeKey()
{
return (scopeKey);
}
/*******************************************************************************
** Set scopeKey.
*******************************************************************************/
public void setScopeKey(String scopeKey)
{
this.scopeKey = scopeKey;
}
/*******************************************************************************
** Set scopeKey fluently.
*******************************************************************************/
public EdiControlNumber withScopeKey(String scopeKey)
{
this.scopeKey = scopeKey;
return (this);
}
/*******************************************************************************
** Get level.
*******************************************************************************/
public String getLevel()
{
return (level);
}
/*******************************************************************************
** Set level.
*******************************************************************************/
public void setLevel(String level)
{
this.level = level;
}
/*******************************************************************************
** Set level fluently.
*******************************************************************************/
public EdiControlNumber withLevel(String level)
{
this.level = level;
return (this);
}
/*******************************************************************************
** Get lastValue.
*******************************************************************************/
public Long getLastValue()
{
return (lastValue);
}
/*******************************************************************************
** Set lastValue.
*******************************************************************************/
public void setLastValue(Long lastValue)
{
this.lastValue = lastValue;
}
/*******************************************************************************
** Set lastValue fluently.
*******************************************************************************/
public EdiControlNumber withLastValue(Long lastValue)
{
this.lastValue = lastValue;
return (this);
}
/*******************************************************************************
** Get minValue.
*******************************************************************************/
public Long getMinValue()
{
return (minValue);
}
/*******************************************************************************
** Set minValue.
*******************************************************************************/
public void setMinValue(Long minValue)
{
this.minValue = minValue;
}
/*******************************************************************************
** Set minValue fluently.
*******************************************************************************/
public EdiControlNumber withMinValue(Long minValue)
{
this.minValue = minValue;
return (this);
}
/*******************************************************************************
** Get maxValue.
*******************************************************************************/
public Long getMaxValue()
{
return (maxValue);
}
/*******************************************************************************
** Set maxValue.
*******************************************************************************/
public void setMaxValue(Long maxValue)
{
this.maxValue = maxValue;
}
/*******************************************************************************
** Set maxValue fluently.
*******************************************************************************/
public EdiControlNumber withMaxValue(Long maxValue)
{
this.maxValue = maxValue;
return (this);
}
/*******************************************************************************
** Get wrapAllowed.
*******************************************************************************/
public Boolean getWrapAllowed()
{
return (wrapAllowed);
}
/*******************************************************************************
** Set wrapAllowed.
*******************************************************************************/
public void setWrapAllowed(Boolean wrapAllowed)
{
this.wrapAllowed = wrapAllowed;
}
/*******************************************************************************
** Set wrapAllowed fluently.
*******************************************************************************/
public EdiControlNumber withWrapAllowed(Boolean wrapAllowed)
{
this.wrapAllowed = wrapAllowed;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiControlNumber withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiControlNumber withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiControlNumber withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
@@ -1,343 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_event record.
*******************************************************************************/
public class EdiEvent extends QRecordEntity
{
public static final String TABLE_NAME = "edi_event";
@QField(backendName = "inbox_id", possibleValueSourceName = "edi_inbox")
private Integer inboxId;
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "interchange_id", possibleValueSourceName = "edi_interchange")
private Integer interchangeId;
@QField(backendName = "transaction_id", possibleValueSourceName = "edi_transaction")
private Integer transactionId;
@QField(backendName = "at")
private Instant at;
@QField(backendName = "type", maxLength = 255, possibleValueSourceName = "edi_event_type")
private String type;
@QField(backendName = "detail", maxLength = 10000)
private String detail;
@QField(backendName = "user_name", maxLength = 255)
private String userName;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiEvent withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get interchangeId.
*******************************************************************************/
public Integer getInterchangeId()
{
return (interchangeId);
}
/*******************************************************************************
** Set interchangeId.
*******************************************************************************/
public void setInterchangeId(Integer interchangeId)
{
this.interchangeId = interchangeId;
}
/*******************************************************************************
** Set interchangeId fluently.
*******************************************************************************/
public EdiEvent withInterchangeId(Integer interchangeId)
{
this.interchangeId = interchangeId;
return (this);
}
/*******************************************************************************
** Get transactionId.
*******************************************************************************/
public Integer getTransactionId()
{
return (transactionId);
}
/*******************************************************************************
** Set transactionId.
*******************************************************************************/
public void setTransactionId(Integer transactionId)
{
this.transactionId = transactionId;
}
/*******************************************************************************
** Set transactionId fluently.
*******************************************************************************/
public EdiEvent withTransactionId(Integer transactionId)
{
this.transactionId = transactionId;
return (this);
}
/*******************************************************************************
** Get at.
*******************************************************************************/
public Instant getAt()
{
return (at);
}
/*******************************************************************************
** Set at.
*******************************************************************************/
public void setAt(Instant at)
{
this.at = at;
}
/*******************************************************************************
** Set at fluently.
*******************************************************************************/
public EdiEvent withAt(Instant at)
{
this.at = at;
return (this);
}
/*******************************************************************************
** Get type.
*******************************************************************************/
public String getType()
{
return (type);
}
/*******************************************************************************
** Set type.
*******************************************************************************/
public void setType(String type)
{
this.type = type;
}
/*******************************************************************************
** Set type fluently.
*******************************************************************************/
public EdiEvent withType(String type)
{
this.type = type;
return (this);
}
/*******************************************************************************
** Get detail.
*******************************************************************************/
public String getDetail()
{
return (detail);
}
/*******************************************************************************
** Set detail.
*******************************************************************************/
public void setDetail(String detail)
{
this.detail = detail;
}
/*******************************************************************************
** Set detail fluently.
*******************************************************************************/
public EdiEvent withDetail(String detail)
{
this.detail = detail;
return (this);
}
/*******************************************************************************
** Get userName.
*******************************************************************************/
public String getUserName()
{
return (userName);
}
/*******************************************************************************
** Set userName.
*******************************************************************************/
public void setUserName(String userName)
{
this.userName = userName;
}
/*******************************************************************************
** Set userName fluently.
*******************************************************************************/
public EdiEvent withUserName(String userName)
{
this.userName = userName;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiEvent withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiEvent withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get inboxId.
*******************************************************************************/
public Integer getInboxId()
{
return (inboxId);
}
/*******************************************************************************
** Set inboxId.
*******************************************************************************/
public void setInboxId(Integer value)
{
inboxId = value;
}
/*******************************************************************************
** Configure inboxId.
*******************************************************************************/
public EdiEvent withInboxId(Integer value)
{
inboxId = value;
return (this);
}
}
@@ -1,594 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_exception record.
*******************************************************************************/
public class EdiException extends QRecordEntity
{
public static final String TABLE_NAME = "edi_exception";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "type", maxLength = 255, possibleValueSourceName = "edi_exception_type")
private String type;
@QField(backendName = "status", maxLength = 255, possibleValueSourceName = "edi_exception_status")
private String status;
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
private Integer partnerId;
@QField(backendName = "inbox_id", possibleValueSourceName = "edi_inbox")
private Integer inboxId;
@QField(backendName = "interchange_id", possibleValueSourceName = "edi_interchange")
private Integer interchangeId;
@QField(backendName = "transaction_id", possibleValueSourceName = "edi_transaction")
private Integer transactionId;
@QField(backendName = "outbound_message_id", possibleValueSourceName = "edi_outbound_message")
private Integer outboundMessageId;
@QField(backendName = "summary", maxLength = 255)
private String summary;
@QField(backendName = "detail", maxLength = 10000)
private String detail;
@QField(backendName = "assigned_to_user_id", maxLength = 255)
private String assignedToUserId;
@QField(backendName = "resolved_by", maxLength = 255)
private String resolvedBy;
@QField(backendName = "resolved_at")
private Instant resolvedAt;
@QField(backendName = "resolution", maxLength = 10000)
private String resolution;
@QField(backendName = "occurrences")
private Integer occurrences;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiException withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get type.
*******************************************************************************/
public String getType()
{
return (type);
}
/*******************************************************************************
** Set type.
*******************************************************************************/
public void setType(String type)
{
this.type = type;
}
/*******************************************************************************
** Set type fluently.
*******************************************************************************/
public EdiException withType(String type)
{
this.type = type;
return (this);
}
/*******************************************************************************
** Get status.
*******************************************************************************/
public String getStatus()
{
return (status);
}
/*******************************************************************************
** Set status.
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Set status fluently.
*******************************************************************************/
public EdiException withStatus(String status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Get partnerId.
*******************************************************************************/
public Integer getPartnerId()
{
return (partnerId);
}
/*******************************************************************************
** Set partnerId.
*******************************************************************************/
public void setPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
}
/*******************************************************************************
** Set partnerId fluently.
*******************************************************************************/
public EdiException withPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
return (this);
}
/*******************************************************************************
** Get inboxId.
*******************************************************************************/
public Integer getInboxId()
{
return (inboxId);
}
/*******************************************************************************
** Set inboxId.
*******************************************************************************/
public void setInboxId(Integer inboxId)
{
this.inboxId = inboxId;
}
/*******************************************************************************
** Set inboxId fluently.
*******************************************************************************/
public EdiException withInboxId(Integer inboxId)
{
this.inboxId = inboxId;
return (this);
}
/*******************************************************************************
** Get interchangeId.
*******************************************************************************/
public Integer getInterchangeId()
{
return (interchangeId);
}
/*******************************************************************************
** Set interchangeId.
*******************************************************************************/
public void setInterchangeId(Integer interchangeId)
{
this.interchangeId = interchangeId;
}
/*******************************************************************************
** Set interchangeId fluently.
*******************************************************************************/
public EdiException withInterchangeId(Integer interchangeId)
{
this.interchangeId = interchangeId;
return (this);
}
/*******************************************************************************
** Get transactionId.
*******************************************************************************/
public Integer getTransactionId()
{
return (transactionId);
}
/*******************************************************************************
** Set transactionId.
*******************************************************************************/
public void setTransactionId(Integer transactionId)
{
this.transactionId = transactionId;
}
/*******************************************************************************
** Set transactionId fluently.
*******************************************************************************/
public EdiException withTransactionId(Integer transactionId)
{
this.transactionId = transactionId;
return (this);
}
/*******************************************************************************
** Get outboundMessageId.
*******************************************************************************/
public Integer getOutboundMessageId()
{
return (outboundMessageId);
}
/*******************************************************************************
** Set outboundMessageId.
*******************************************************************************/
public void setOutboundMessageId(Integer outboundMessageId)
{
this.outboundMessageId = outboundMessageId;
}
/*******************************************************************************
** Set outboundMessageId fluently.
*******************************************************************************/
public EdiException withOutboundMessageId(Integer outboundMessageId)
{
this.outboundMessageId = outboundMessageId;
return (this);
}
/*******************************************************************************
** Get summary.
*******************************************************************************/
public String getSummary()
{
return (summary);
}
/*******************************************************************************
** Set summary.
*******************************************************************************/
public void setSummary(String summary)
{
this.summary = summary;
}
/*******************************************************************************
** Set summary fluently.
*******************************************************************************/
public EdiException withSummary(String summary)
{
this.summary = summary;
return (this);
}
/*******************************************************************************
** Get detail.
*******************************************************************************/
public String getDetail()
{
return (detail);
}
/*******************************************************************************
** Set detail.
*******************************************************************************/
public void setDetail(String detail)
{
this.detail = detail;
}
/*******************************************************************************
** Set detail fluently.
*******************************************************************************/
public EdiException withDetail(String detail)
{
this.detail = detail;
return (this);
}
/*******************************************************************************
** Get assignedToUserId.
*******************************************************************************/
public String getAssignedToUserId()
{
return (assignedToUserId);
}
/*******************************************************************************
** Set assignedToUserId.
*******************************************************************************/
public void setAssignedToUserId(String assignedToUserId)
{
this.assignedToUserId = assignedToUserId;
}
/*******************************************************************************
** Set assignedToUserId fluently.
*******************************************************************************/
public EdiException withAssignedToUserId(String assignedToUserId)
{
this.assignedToUserId = assignedToUserId;
return (this);
}
/*******************************************************************************
** Get resolvedBy.
*******************************************************************************/
public String getResolvedBy()
{
return (resolvedBy);
}
/*******************************************************************************
** Set resolvedBy.
*******************************************************************************/
public void setResolvedBy(String resolvedBy)
{
this.resolvedBy = resolvedBy;
}
/*******************************************************************************
** Set resolvedBy fluently.
*******************************************************************************/
public EdiException withResolvedBy(String resolvedBy)
{
this.resolvedBy = resolvedBy;
return (this);
}
/*******************************************************************************
** Get resolvedAt.
*******************************************************************************/
public Instant getResolvedAt()
{
return (resolvedAt);
}
/*******************************************************************************
** Set resolvedAt.
*******************************************************************************/
public void setResolvedAt(Instant resolvedAt)
{
this.resolvedAt = resolvedAt;
}
/*******************************************************************************
** Set resolvedAt fluently.
*******************************************************************************/
public EdiException withResolvedAt(Instant resolvedAt)
{
this.resolvedAt = resolvedAt;
return (this);
}
/*******************************************************************************
** Get resolution.
*******************************************************************************/
public String getResolution()
{
return (resolution);
}
/*******************************************************************************
** Set resolution.
*******************************************************************************/
public void setResolution(String resolution)
{
this.resolution = resolution;
}
/*******************************************************************************
** Set resolution fluently.
*******************************************************************************/
public EdiException withResolution(String resolution)
{
this.resolution = resolution;
return (this);
}
/*******************************************************************************
** Get occurrences.
*******************************************************************************/
public Integer getOccurrences()
{
return (occurrences);
}
/*******************************************************************************
** Set occurrences.
*******************************************************************************/
public void setOccurrences(Integer occurrences)
{
this.occurrences = occurrences;
}
/*******************************************************************************
** Set occurrences fluently.
*******************************************************************************/
public EdiException withOccurrences(Integer occurrences)
{
this.occurrences = occurrences;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiException withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiException withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiException withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
@@ -1,466 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_functional_group record.
*******************************************************************************/
public class EdiFunctionalGroup extends QRecordEntity
{
public static final String TABLE_NAME = "edi_functional_group";
@QField(backendName = "inbound_position")
private Integer inboundPosition;
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "interchange_id", possibleValueSourceName = "edi_interchange")
private Integer interchangeId;
@QField(backendName = "sender_id", maxLength = 255)
private String senderId;
@QField(backendName = "functional_id_code", maxLength = 255)
private String functionalIdCode;
@QField(backendName = "control_number", maxLength = 255)
private String controlNumber;
@QField(backendName = "version", maxLength = 255)
private String version;
@QField(backendName = "transaction_count")
private Integer transactionCount;
@QField(backendName = "ack_status", maxLength = 255, possibleValueSourceName = "edi_ack_status")
private String ackStatus;
@QField(backendName = "ack_due_at")
private Instant ackDueAt;
@QField(backendName = "ack_received_at")
private Instant ackReceivedAt;
@QField(backendName = "ack_transaction_id", possibleValueSourceName = "edi_transaction")
private Integer ackTransactionId;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiFunctionalGroup withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get interchangeId.
*******************************************************************************/
public Integer getInterchangeId()
{
return (interchangeId);
}
/*******************************************************************************
** Set interchangeId.
*******************************************************************************/
public void setInterchangeId(Integer interchangeId)
{
this.interchangeId = interchangeId;
}
/*******************************************************************************
** Set interchangeId fluently.
*******************************************************************************/
public EdiFunctionalGroup withInterchangeId(Integer interchangeId)
{
this.interchangeId = interchangeId;
return (this);
}
/*******************************************************************************
** Get senderId.
*******************************************************************************/
public String getSenderId()
{
return (senderId);
}
/*******************************************************************************
** Set senderId.
*******************************************************************************/
public void setSenderId(String senderId)
{
this.senderId = senderId;
}
/*******************************************************************************
** Set senderId fluently.
*******************************************************************************/
public EdiFunctionalGroup withSenderId(String senderId)
{
this.senderId = senderId;
return (this);
}
/*******************************************************************************
** Get functionalIdCode.
*******************************************************************************/
public String getFunctionalIdCode()
{
return (functionalIdCode);
}
/*******************************************************************************
** Set functionalIdCode.
*******************************************************************************/
public void setFunctionalIdCode(String functionalIdCode)
{
this.functionalIdCode = functionalIdCode;
}
/*******************************************************************************
** Set functionalIdCode fluently.
*******************************************************************************/
public EdiFunctionalGroup withFunctionalIdCode(String functionalIdCode)
{
this.functionalIdCode = functionalIdCode;
return (this);
}
/*******************************************************************************
** Get controlNumber.
*******************************************************************************/
public String getControlNumber()
{
return (controlNumber);
}
/*******************************************************************************
** Set controlNumber.
*******************************************************************************/
public void setControlNumber(String controlNumber)
{
this.controlNumber = controlNumber;
}
/*******************************************************************************
** Set controlNumber fluently.
*******************************************************************************/
public EdiFunctionalGroup withControlNumber(String controlNumber)
{
this.controlNumber = controlNumber;
return (this);
}
/*******************************************************************************
** Get version.
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Set version.
*******************************************************************************/
public void setVersion(String version)
{
this.version = version;
}
/*******************************************************************************
** Set version fluently.
*******************************************************************************/
public EdiFunctionalGroup withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Get transactionCount.
*******************************************************************************/
public Integer getTransactionCount()
{
return (transactionCount);
}
/*******************************************************************************
** Set transactionCount.
*******************************************************************************/
public void setTransactionCount(Integer transactionCount)
{
this.transactionCount = transactionCount;
}
/*******************************************************************************
** Set transactionCount fluently.
*******************************************************************************/
public EdiFunctionalGroup withTransactionCount(Integer transactionCount)
{
this.transactionCount = transactionCount;
return (this);
}
/*******************************************************************************
** Get ackStatus.
*******************************************************************************/
public String getAckStatus()
{
return (ackStatus);
}
/*******************************************************************************
** Set ackStatus.
*******************************************************************************/
public void setAckStatus(String ackStatus)
{
this.ackStatus = ackStatus;
}
/*******************************************************************************
** Set ackStatus fluently.
*******************************************************************************/
public EdiFunctionalGroup withAckStatus(String ackStatus)
{
this.ackStatus = ackStatus;
return (this);
}
/*******************************************************************************
** Get ackDueAt.
*******************************************************************************/
public Instant getAckDueAt()
{
return (ackDueAt);
}
/*******************************************************************************
** Set ackDueAt.
*******************************************************************************/
public void setAckDueAt(Instant ackDueAt)
{
this.ackDueAt = ackDueAt;
}
/*******************************************************************************
** Set ackDueAt fluently.
*******************************************************************************/
public EdiFunctionalGroup withAckDueAt(Instant ackDueAt)
{
this.ackDueAt = ackDueAt;
return (this);
}
/*******************************************************************************
** Get ackReceivedAt.
*******************************************************************************/
public Instant getAckReceivedAt()
{
return (ackReceivedAt);
}
/*******************************************************************************
** Set ackReceivedAt.
*******************************************************************************/
public void setAckReceivedAt(Instant ackReceivedAt)
{
this.ackReceivedAt = ackReceivedAt;
}
/*******************************************************************************
** Set ackReceivedAt fluently.
*******************************************************************************/
public EdiFunctionalGroup withAckReceivedAt(Instant ackReceivedAt)
{
this.ackReceivedAt = ackReceivedAt;
return (this);
}
/*******************************************************************************
** Get ackTransactionId.
*******************************************************************************/
public Integer getAckTransactionId()
{
return (ackTransactionId);
}
/*******************************************************************************
** Set ackTransactionId.
*******************************************************************************/
public void setAckTransactionId(Integer ackTransactionId)
{
this.ackTransactionId = ackTransactionId;
}
/*******************************************************************************
** Set ackTransactionId fluently.
*******************************************************************************/
public EdiFunctionalGroup withAckTransactionId(Integer ackTransactionId)
{
this.ackTransactionId = ackTransactionId;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiFunctionalGroup withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiFunctionalGroup withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** One-based occurrence within the parent payload, interchange or group.
*******************************************************************************/
public Integer getInboundPosition()
{
return (inboundPosition);
}
/*******************************************************************************
** Set the stable source position used for retry checkpoints.
*******************************************************************************/
public void setInboundPosition(Integer inboundPosition)
{
this.inboundPosition = inboundPosition;
}
/*******************************************************************************
** Fluently set the source position.
*******************************************************************************/
public EdiFunctionalGroup withInboundPosition(Integer inboundPosition)
{
this.inboundPosition = inboundPosition;
return (this);
}
}
@@ -1,532 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2025. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.kingsrook.qbits.edi.model;
import java.time.Instant;
import com.kingsrook.qqq.backend.core.model.data.QField;
import com.kingsrook.qqq.backend.core.model.data.QRecordEntity;
/*******************************************************************************
** Persistent edi_guide record.
*******************************************************************************/
public class EdiGuide extends QRecordEntity
{
public static final String TABLE_NAME = "edi_guide";
@QField(backendName = "id", isPrimaryKey = true, isEditable = false)
private Integer id;
@QField(backendName = "name", maxLength = 255)
private String name;
@QField(backendName = "standard", maxLength = 255, possibleValueSourceName = "edi_standard")
private String standard;
@QField(backendName = "version", maxLength = 255)
private String version;
@QField(backendName = "transaction_set", maxLength = 255)
private String transactionSet;
@QField(backendName = "functional_id_code", maxLength = 255)
private String functionalIdCode;
@QField(backendName = "partner_id", possibleValueSourceName = "edi_partner")
private Integer partnerId;
@QField(backendName = "parent_guide_id", possibleValueSourceName = "edi_guide")
private Integer parentGuideId;
@QField(backendName = "revision")
private Integer revision;
@QField(backendName = "status", maxLength = 255, possibleValueSourceName = "edi_guide_status")
private String status;
@QField(backendName = "source", maxLength = 255, possibleValueSourceName = "edi_guide_source")
private String source;
@QField(backendName = "description", maxLength = 10000)
private String description;
@QField(backendName = "compiled_checksum", maxLength = 255)
private String compiledChecksum;
@QField(backendName = "create_date", isEditable = false)
private Instant createDate;
@QField(backendName = "modify_date", isEditable = false)
private Instant modifyDate;
@QField(backendName = "modified_by", maxLength = 255)
private String modifiedBy;
/*******************************************************************************
** Get id.
*******************************************************************************/
public Integer getId()
{
return (id);
}
/*******************************************************************************
** Set id.
*******************************************************************************/
public void setId(Integer id)
{
this.id = id;
}
/*******************************************************************************
** Set id fluently.
*******************************************************************************/
public EdiGuide withId(Integer id)
{
this.id = id;
return (this);
}
/*******************************************************************************
** Get name.
*******************************************************************************/
public String getName()
{
return (name);
}
/*******************************************************************************
** Set name.
*******************************************************************************/
public void setName(String name)
{
this.name = name;
}
/*******************************************************************************
** Set name fluently.
*******************************************************************************/
public EdiGuide withName(String name)
{
this.name = name;
return (this);
}
/*******************************************************************************
** Get standard.
*******************************************************************************/
public String getStandard()
{
return (standard);
}
/*******************************************************************************
** Set standard.
*******************************************************************************/
public void setStandard(String standard)
{
this.standard = standard;
}
/*******************************************************************************
** Set standard fluently.
*******************************************************************************/
public EdiGuide withStandard(String standard)
{
this.standard = standard;
return (this);
}
/*******************************************************************************
** Get version.
*******************************************************************************/
public String getVersion()
{
return (version);
}
/*******************************************************************************
** Set version.
*******************************************************************************/
public void setVersion(String version)
{
this.version = version;
}
/*******************************************************************************
** Set version fluently.
*******************************************************************************/
public EdiGuide withVersion(String version)
{
this.version = version;
return (this);
}
/*******************************************************************************
** Get transactionSet.
*******************************************************************************/
public String getTransactionSet()
{
return (transactionSet);
}
/*******************************************************************************
** Set transactionSet.
*******************************************************************************/
public void setTransactionSet(String transactionSet)
{
this.transactionSet = transactionSet;
}
/*******************************************************************************
** Set transactionSet fluently.
*******************************************************************************/
public EdiGuide withTransactionSet(String transactionSet)
{
this.transactionSet = transactionSet;
return (this);
}
/*******************************************************************************
** Get functionalIdCode.
*******************************************************************************/
public String getFunctionalIdCode()
{
return (functionalIdCode);
}
/*******************************************************************************
** Set functionalIdCode.
*******************************************************************************/
public void setFunctionalIdCode(String functionalIdCode)
{
this.functionalIdCode = functionalIdCode;
}
/*******************************************************************************
** Set functionalIdCode fluently.
*******************************************************************************/
public EdiGuide withFunctionalIdCode(String functionalIdCode)
{
this.functionalIdCode = functionalIdCode;
return (this);
}
/*******************************************************************************
** Get partnerId.
*******************************************************************************/
public Integer getPartnerId()
{
return (partnerId);
}
/*******************************************************************************
** Set partnerId.
*******************************************************************************/
public void setPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
}
/*******************************************************************************
** Set partnerId fluently.
*******************************************************************************/
public EdiGuide withPartnerId(Integer partnerId)
{
this.partnerId = partnerId;
return (this);
}
/*******************************************************************************
** Get parentGuideId.
*******************************************************************************/
public Integer getParentGuideId()
{
return (parentGuideId);
}
/*******************************************************************************
** Set parentGuideId.
*******************************************************************************/
public void setParentGuideId(Integer parentGuideId)
{
this.parentGuideId = parentGuideId;
}
/*******************************************************************************
** Set parentGuideId fluently.
*******************************************************************************/
public EdiGuide withParentGuideId(Integer parentGuideId)
{
this.parentGuideId = parentGuideId;
return (this);
}
/*******************************************************************************
** Get revision.
*******************************************************************************/
public Integer getRevision()
{
return (revision);
}
/*******************************************************************************
** Set revision.
*******************************************************************************/
public void setRevision(Integer revision)
{
this.revision = revision;
}
/*******************************************************************************
** Set revision fluently.
*******************************************************************************/
public EdiGuide withRevision(Integer revision)
{
this.revision = revision;
return (this);
}
/*******************************************************************************
** Get status.
*******************************************************************************/
public String getStatus()
{
return (status);
}
/*******************************************************************************
** Set status.
*******************************************************************************/
public void setStatus(String status)
{
this.status = status;
}
/*******************************************************************************
** Set status fluently.
*******************************************************************************/
public EdiGuide withStatus(String status)
{
this.status = status;
return (this);
}
/*******************************************************************************
** Get source.
*******************************************************************************/
public String getSource()
{
return (source);
}
/*******************************************************************************
** Set source.
*******************************************************************************/
public void setSource(String source)
{
this.source = source;
}
/*******************************************************************************
** Set source fluently.
*******************************************************************************/
public EdiGuide withSource(String source)
{
this.source = source;
return (this);
}
/*******************************************************************************
** Get description.
*******************************************************************************/
public String getDescription()
{
return (description);
}
/*******************************************************************************
** Set description.
*******************************************************************************/
public void setDescription(String description)
{
this.description = description;
}
/*******************************************************************************
** Set description fluently.
*******************************************************************************/
public EdiGuide withDescription(String description)
{
this.description = description;
return (this);
}
/*******************************************************************************
** Get compiledChecksum.
*******************************************************************************/
public String getCompiledChecksum()
{
return (compiledChecksum);
}
/*******************************************************************************
** Set compiledChecksum.
*******************************************************************************/
public void setCompiledChecksum(String compiledChecksum)
{
this.compiledChecksum = compiledChecksum;
}
/*******************************************************************************
** Set compiledChecksum fluently.
*******************************************************************************/
public EdiGuide withCompiledChecksum(String compiledChecksum)
{
this.compiledChecksum = compiledChecksum;
return (this);
}
/*******************************************************************************
** Get createDate.
*******************************************************************************/
public Instant getCreateDate()
{
return (createDate);
}
/*******************************************************************************
** Set createDate.
*******************************************************************************/
public void setCreateDate(Instant createDate)
{
this.createDate = createDate;
}
/*******************************************************************************
** Set createDate fluently.
*******************************************************************************/
public EdiGuide withCreateDate(Instant createDate)
{
this.createDate = createDate;
return (this);
}
/*******************************************************************************
** Get modifyDate.
*******************************************************************************/
public Instant getModifyDate()
{
return (modifyDate);
}
/*******************************************************************************
** Set modifyDate.
*******************************************************************************/
public void setModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
}
/*******************************************************************************
** Set modifyDate fluently.
*******************************************************************************/
public EdiGuide withModifyDate(Instant modifyDate)
{
this.modifyDate = modifyDate;
return (this);
}
/*******************************************************************************
** Get modifiedBy.
*******************************************************************************/
public String getModifiedBy()
{
return (modifiedBy);
}
/*******************************************************************************
** Set modifiedBy.
*******************************************************************************/
public void setModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
}
/*******************************************************************************
** Set modifiedBy fluently.
*******************************************************************************/
public EdiGuide withModifiedBy(String modifiedBy)
{
this.modifiedBy = modifiedBy;
return (this);
}
}
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more