///|
/// Resolution state after merging multiple payment status reports.
pub(all) enum TimelineState {
TimelineAwaiting
TimelineInProgress
TimelineSettled
TimelineRejected
TimelineConflict
TimelineUnknown
}
///|
pub fn TimelineState::name(self : TimelineState) -> String {
match self {
TimelineAwaiting => "AWAITING_STATUS"
TimelineInProgress => "IN_PROGRESS"
TimelineSettled => "SETTLED"
TimelineRejected => "REJECTED"
TimelineConflict => "STATUS_CONFLICT"
TimelineUnknown => "UNKNOWN_STATUS"
}
}
///|
pub(all) enum TimelineSeverity {
TimelineInfo
TimelineWarning
TimelineError
}
///|
pub fn TimelineSeverity::name(self : TimelineSeverity) -> String {
match self {
TimelineInfo => "INFO"
TimelineWarning => "WARNING"
TimelineError => "ERROR"
}
}
///|
/// One transaction-level status notification.
pub struct StatusEventRecord {
message_id : String?
creation_datetime : String?
end_to_end_id : String
status : String
reason_code : String?
original_amount : Decimal?
original_currency : String?
}
///|
/// A transition or data-quality issue found in a status history.
pub struct TimelineIssue {
code : String
severity : TimelineSeverity
message : String
}
///|
/// Merged history and resolved state for one payment.
pub struct StatusTimelineCase {
end_to_end_id : String
amount : Decimal
currency : String
events : Array[StatusEventRecord]
latest_status : String?
state : TimelineState
issues : Array[TimelineIssue]
action : String
}
///|
/// Result of merging one payment source with multiple status reports.
pub struct StatusTimelineReport {
message_count : Int
event_count : Int
cases : Array[StatusTimelineCase]
orphan_status_ids : Array[String]
orphan_events : Array[StatusEventRecord]
diagnostics : Array[Diagnostic]
}
///|
fn timeline_status_valid(value : String) -> Bool {
value == "ACCP" ||
value == "ACSC" ||
value == "ACSP" ||
value == "ACTC" ||
value == "ACWC" ||
value == "PDNG" ||
value == "RCVD" ||
value == "RJCT"
}
///|
fn timeline_terminal(value : String) -> Bool {
value == "ACSC" || value == "RJCT"
}
///|
fn push_timeline_issue(
issues : Array[TimelineIssue],
code : String,
severity : TimelineSeverity,
message : String,
) -> Unit {
issues.push({ code, severity, message, })
}
///|
/// Parse all transaction status events from pain.002 or pacs.002.
pub fn status_events(xml_text : String) -> Array[StatusEventRecord] raise XmlParseError {
let reader = XmlReader::from_string(xml_text)
let path : Array[String] = []
let events : Array[StatusEventRecord] = []
let mut message_id : String? = None
let mut creation_datetime : String? = None
let mut in_transaction = false
let mut current_id : String? = None
let mut current_status : String? = None
let mut current_reason : String? = None
let mut current_amount_text : String? = None
let mut current_amount_currency : String? = None
for ;; {
match reader.read_event().kind {
Start(element) => {
path.push(element.name)
let current_path = join_path(path)
if current_path.has_suffix("/TxInfAndSts") {
in_transaction = true
current_id = None
current_status = None
current_reason = None
current_amount_text = None
current_amount_currency = None
}
if current_path.has_suffix("/OrgnlInstdAmt") ||
current_path.has_suffix("/OrgnlIntrBkSttlmAmt") {
current_amount_currency = element.get("Ccy")
}
}
Empty(element) => {
path.push(element.name)
let current_path = join_path(path)
if current_path.has_suffix("/OrgnlInstdAmt") ||
current_path.has_suffix("/OrgnlIntrBkSttlmAmt") {
current_amount_currency = element.get("Ccy")
}
let _ = path.remove(path.length() - 1)
}
Text(content) => {
let current_path = join_path(path)
if current_path.has_suffix("/GrpHdr/MsgId") {
message_id = Some(content)
} else if current_path.has_suffix("/GrpHdr/CreDtTm") {
creation_datetime = Some(content)
} else if current_path.has_suffix("/TxInfAndSts/OrgnlEndToEndId") {
current_id = Some(content)
} else if current_path.has_suffix("/TxInfAndSts/TxSts") {
current_status = Some(content)
} else if current_path.has_suffix("/TxInfAndSts/StsRsnInf/Rsn/Cd") {
current_reason = Some(content)
} else if current_path.has_suffix("/OrgnlInstdAmt") ||
current_path.has_suffix("/OrgnlIntrBkSttlmAmt") {
current_amount_text = Some(content)
}
}
End(name) => {
if name == "TxInfAndSts" && in_transaction {
match (current_id, current_status) {
(Some(id), Some(status)) =>
events.push({
message_id,
creation_datetime,
end_to_end_id: id,
status,
reason_code: current_reason,
original_amount: match current_amount_text {
Some(value) => Decimal::parse(value)
None => None
},
original_currency: current_amount_currency,
})
_ => ()
}
in_transaction = false
}
if path.length() > 0 {
let _ = path.remove(path.length() - 1)
}
}
Eof => break
}
}
events
}
///|
fn compare_status_events(left : StatusEventRecord, right : StatusEventRecord) -> Int {
let left_time = match left.creation_datetime {
Some(value) => value
None => ""
}
let right_time = match right.creation_datetime {
Some(value) => value
None => ""
}
let time_order = left_time.compare(right_time)
if time_order != 0 {
return time_order
}
let left_message = match left.message_id {
Some(value) => value
None => ""
}
let right_message = match right.message_id {
Some(value) => value
None => ""
}
let message_order = left_message.compare(right_message)
if message_order != 0 {
return message_order
}
left.status.compare(right.status)
}
///|
fn sort_status_events(events : Array[StatusEventRecord]) -> Array[StatusEventRecord] {
let sorted : Array[StatusEventRecord] = []
for event in events {
let mut position = sorted.length()
for i = 0; i < sorted.length(); i = i + 1 {
if compare_status_events(event, sorted[i]) < 0 {
position = i
break
}
}
sorted.insert(position, event)
}
sorted
}
///|
fn event_timestamp(event : StatusEventRecord) -> String {
match event.creation_datetime {
Some(value) => value
None => ""
}
}
///|
fn event_message_id(event : StatusEventRecord) -> String {
match event.message_id {
Some(value) => value
None => ""
}
}
///|
fn valid_non_terminal(status : String) -> Bool {
status != "ACSC" && status != "RJCT" && timeline_status_valid(status)
}
///|
fn timeline_action(state : TimelineState) -> String {
match state {
TimelineAwaiting => "Request a status report from the payment provider."
TimelineInProgress => "Wait for the next status report or query the provider again."
TimelineSettled => "Archive the status history as settlement evidence."
TimelineRejected => "Review the rejection reason and decide whether to resend with a new end-to-end ID."
TimelineConflict => "Suspend automated completion and reconcile the conflicting status notifications manually."
TimelineUnknown => "Correct the unsupported status code before using the payment as settled."
}
}
///|
fn build_timeline_case(
payment : PaymentRecord,
unsorted_events : Array[StatusEventRecord],
) -> StatusTimelineCase {
let events = sort_status_events(unsorted_events)
let issues : Array[TimelineIssue] = []
let seen_events : Map[String, Bool] = Map([])
let mut latest_status : String? = None
for event in events {
if !timeline_status_valid(event.status) {
push_timeline_issue(
issues,
"STATUS_CODE_INVALID",
TimelineError,
"Unsupported transaction status " + event.status + ".",
)
}
match event.original_amount {
Some(amount) =>
if amount.compare(payment.amount) != 0 {
push_timeline_issue(
issues,
"STATUS_AMOUNT_MISMATCH",
TimelineError,
"Status report original amount " +
amount.to_string() +
" does not match payment amount " +
payment.amount.to_string() +
".",
)
}
None => ()
}
match event.original_currency {
Some(currency) =>
if currency != payment.currency {
push_timeline_issue(
issues,
"STATUS_CURRENCY_MISMATCH",
TimelineError,
"Status report original currency " +
currency +
" does not match payment currency " +
payment.currency +
".",
)
}
None => ()
}
let duplicate_key = event_message_id(event) +
"|" +
event_timestamp(event) +
"|" +
event.status
if seen_events.contains(duplicate_key) {
push_timeline_issue(
issues,
"STATUS_EVENT_DUPLICATE",
TimelineWarning,
"The same status event appears more than once at " +
event_timestamp(event) +
".",
)
} else {
seen_events.set(duplicate_key, true)
}
latest_status = Some(event.status)
}
for i = 1; i < events.length(); i = i + 1 {
let previous = events[i - 1]
let current = events[i]
if timeline_terminal(previous.status) && previous.status != current.status {
push_timeline_issue(
issues,
"TERMINAL_STATUS_REGRESSION",
TimelineError,
"Terminal status " +
previous.status +
" is followed by " +
current.status +
" at " +
event_timestamp(current) +
".",
)
}
if event_timestamp(previous) == event_timestamp(current) &&
previous.status != current.status {
push_timeline_issue(
issues,
"STATUS_TIMELINE_CONFLICT",
TimelineError,
"Conflicting statuses " +
previous.status +
" and " +
current.status +
" share timestamp " +
event_timestamp(current) +
".",
)
}
}
let mut unknown = false
let mut conflict = false
for issue in issues {
if issue.code == "STATUS_CODE_INVALID" {
unknown = true
} else if issue.severity is TimelineError {
conflict = true
}
}
let state = if events.length() == 0 {
TimelineAwaiting
} else if conflict {
TimelineConflict
} else if unknown {
TimelineUnknown
} else {
match latest_status {
Some(value) =>
if value == "ACSC" {
TimelineSettled
} else if value == "RJCT" {
TimelineRejected
} else if valid_non_terminal(value) {
TimelineInProgress
} else {
TimelineUnknown
}
None => TimelineAwaiting
}
}
{
end_to_end_id: payment.end_to_end_id,
amount: payment.amount,
currency: payment.currency,
events,
latest_status,
state,
issues,
action: timeline_action(state),
}
}
///|
/// Merge multiple status reports and detect conflicting or regressive histories.
pub fn status_timeline_report(
payment_xml : String,
status_xmls : Array[String],
) -> StatusTimelineReport raise XmlParseError {
let payments = payment_records_auto(payment_xml)
let all_events : Array[StatusEventRecord] = []
let diagnostics : Array[Diagnostic] = []
let status_message_counts : Map[String, Int] = Map([])
for status_xml in status_xmls {
let fields = flatten_xml(status_xml)
let message_id = first_text_by_suffix(fields, "/GrpHdr/MsgId")
let creation_datetime = first_text_by_suffix(fields, "/GrpHdr/CreDtTm")
match message_id {
Some(value) => {
let count = match status_message_counts.get(value) {
Some(number) => number
None => 0
}
status_message_counts.set(value, count + 1)
}
None =>
push_diagnostic(
diagnostics,
"STATUS_REPORT_ID_MISSING",
"GrpHdr/MsgId",
"Status report message ID is required.",
)
}
if creation_datetime is None {
push_diagnostic(
diagnostics,
"STATUS_REPORT_TIME_MISSING",
"GrpHdr/CreDtTm",
"Status report creation time is required for chronological merging.",
)
}
let events = status_events(status_xml)
if events.length() == 0 {
push_diagnostic(
diagnostics,
"STATUS_REPORT_EMPTY",
"TxInfAndSts",
"Status report contains no complete transaction status entry.",
)
}
for event in events {
all_events.push(event)
}
}
for pair in status_message_counts {
if pair.1 > 1 {
push_diagnostic(
diagnostics,
"STATUS_MESSAGE_ID_DUPLICATE",
"GrpHdr/MsgId",
"Status report message ID is repeated " + pair.1.to_string() + " times: " + pair.0 + ".",
)
}
}
let payment_ids : Map[String, Bool] = Map([])
let events_by_id : Map[String, Array[StatusEventRecord]] = Map([])
for payment in payments {
payment_ids.set(payment.end_to_end_id, true)
}
for event in all_events {
let records = match events_by_id.get(event.end_to_end_id) {
Some(value) => value
None => {
let value : Array[StatusEventRecord] = []
events_by_id.set(event.end_to_end_id, value)
value
}
}
records.push(event)
}
let cases : Array[StatusTimelineCase] = []
for payment in payments {
let events = match events_by_id.get(payment.end_to_end_id) {
Some(value) => value
None => []
}
cases.push(build_timeline_case(payment, events))
}
let orphan_ids : Array[String] = []
let orphan_seen : Map[String, Bool] = Map([])
let orphan_events : Array[StatusEventRecord] = []
for event in all_events {
if !payment_ids.contains(event.end_to_end_id) {
orphan_events.push(event)
if !orphan_seen.contains(event.end_to_end_id) {
orphan_seen.set(event.end_to_end_id, true)
orphan_ids.push(event.end_to_end_id)
}
}
}
{
message_count: status_xmls.length(),
event_count: all_events.length(),
cases,
orphan_status_ids: orphan_ids,
orphan_events: sort_status_events(orphan_events),
diagnostics,
}
}
///|
pub fn StatusTimelineReport::is_valid(self : StatusTimelineReport) -> Bool {
if self.diagnostics.length() > 0 || self.orphan_status_ids.length() > 0 {
return false
}
for case in self.cases {
for issue in case.issues {
if issue.severity is TimelineError {
return false
}
}
}
true
}
///|
fn status_event_to_json(event : StatusEventRecord) -> Json {
let message_id : Json = match event.message_id {
Some(value) => Json::string(value)
None => Json::null()
}
let creation_datetime : Json = match event.creation_datetime {
Some(value) => Json::string(value)
None => Json::null()
}
let reason_code : Json = match event.reason_code {
Some(value) => Json::string(value)
None => Json::null()
}
let original_amount : Json = match event.original_amount {
Some(value) => Json::string(value.to_string())
None => Json::null()
}
let original_currency : Json = match event.original_currency {
Some(value) => Json::string(value)
None => Json::null()
}
{
"messageId": message_id,
"creationDateTime": creation_datetime,
"status": event.status,
"reasonCode": reason_code,
"originalAmount": original_amount,
"originalCurrency": original_currency,
}
}
///|
pub fn StatusTimelineReport::to_text(self : StatusTimelineReport) -> String {
let output = StringBuilder()
output.write_string("Payment status timeline\n")
output.write_string("Status reports: ")
output.write_string(self.message_count.to_string())
output.write_string("\nEvents: ")
output.write_string(self.event_count.to_string())
for case in self.cases {
output.write_string("\n\n")
output.write_string(case.end_to_end_id)
output.write_string(" | ")
output.write_string(case.amount.to_string())
output.write_string(" ")
output.write_string(case.currency)
output.write_string(" | latest=")
output.write_string(match case.latest_status {
Some(value) => value
None => "(missing)"
})
output.write_string(" | state=")
output.write_string(case.state.name())
output.write_string("\nAction: ")
output.write_string(case.action)
for event in case.events {
output.write_string("\n - ")
output.write_string(match event.creation_datetime {
Some(value) => value
None => "(no time)"
})
output.write_string(" ")
output.write_string(event.status)
match event.reason_code {
Some(value) => {
output.write_string(" reason=")
output.write_string(value)
}
None => ()
}
output.write_string(" message=")
output.write_string(match event.message_id {
Some(value) => value
None => "(missing)"
})
}
for issue in case.issues {
output.write_string("\n [")
output.write_string(issue.severity.name())
output.write_string("] ")
output.write_string(issue.code)
output.write_string(": ")
output.write_string(issue.message)
}
}
if self.diagnostics.length() > 0 {
output.write_string("\n\nStatus report diagnostics:")
for diagnostic in self.diagnostics {
output.write_string("\n- [")
output.write_string(diagnostic.code)
output.write_string("] ")
output.write_string(diagnostic.path)
output.write_string(": ")
output.write_string(diagnostic.message)
}
}
if self.orphan_status_ids.length() > 0 {
output.write_string("\n\nStatus IDs without payment:")
for id in self.orphan_status_ids {
output.write_string("\n- ")
output.write_string(id)
}
}
output.to_string()
}
///|
pub fn StatusTimelineReport::to_json(self : StatusTimelineReport) -> String {
let cases : Array[Json] = []
for case in self.cases {
let events : Array[Json] = []
for event in case.events {
events.push(status_event_to_json(event))
}
let issues : Array[Json] = []
for issue in case.issues {
issues.push({
"code": issue.code,
"severity": issue.severity.name(),
"message": issue.message,
})
}
let latest_status : Json = match case.latest_status {
Some(value) => Json::string(value)
None => Json::null()
}
cases.push({
"endToEndId": case.end_to_end_id,
"amount": case.amount.to_string(),
"currency": case.currency,
"latestStatus": latest_status,
"state": case.state.name(),
"action": case.action,
"events": Json::array(events),
"issues": Json::array(issues),
})
}
let orphan_ids : Array[Json] = []
for id in self.orphan_status_ids {
orphan_ids.push(Json::string(id))
}
let orphan_events : Array[Json] = []
for event in self.orphan_events {
orphan_events.push(status_event_to_json(event))
}
let root = Map([])
root.set("valid", Json::boolean(self.is_valid()))
root.set("statusReportCount", Json::number(self.message_count.to_double()))
root.set("eventCount", Json::number(self.event_count.to_double()))
root.set("cases", Json::array(cases))
root.set("orphanStatusIds", Json::array(orphan_ids))
root.set("orphanEvents", Json::array(orphan_events))
let diagnostics : Array[Json] = []
for diagnostic in self.diagnostics {
diagnostics.push({
"code": diagnostic.code,
"path": diagnostic.path,
"message": diagnostic.message,
})
}
root.set("diagnostics", Json::array(diagnostics))
Json::object(root).stringify(indent=2)
}
///|
pub fn StatusTimelineReport::to_markdown(self : StatusTimelineReport) -> String {
let output = StringBuilder()
output.write_string("# Payment status timeline\n\n")
output.write_string("- Status reports: ")
output.write_string(self.message_count.to_string())
output.write_string("\n- Events: ")
output.write_string(self.event_count.to_string())
output.write_string("\n- Result: ")
if self.is_valid() {
output.write_string("VALID\n")
} else {
output.write_string("ISSUES FOUND\n")
}
output.write_string("\n## Transactions\n\n")
output.write_string("| EndToEndId | Amount | Latest status | State | Events | Issues |\n")
output.write_string("|---|---|---|---|---|---|\n")
for case in self.cases {
output.write_string("| `")
output.write_string(case.end_to_end_id)
output.write_string("` | ")
output.write_string(case.amount.to_string())
output.write_string(" ")
output.write_string(case.currency)
output.write_string(" | ")
output.write_string(match case.latest_status {
Some(value) => value
None => "(missing)"
})
output.write_string(" | `")
output.write_string(case.state.name())
output.write_string("` | ")
output.write_string(case.events.length().to_string())
output.write_string(" | ")
output.write_string(case.issues.length().to_string())
output.write_string(" |\n")
}
output.write_string("\n## Actions and issues\n\n")
for case in self.cases {
output.write_string("### `")
output.write_string(case.end_to_end_id)
output.write_string("`\n\n")
output.write_string(case.action)
output.write_string("\n")
for issue in case.issues {
output.write_string("\n- `")
output.write_string(issue.code)
output.write_string("`: ")
output.write_string(issue.message)
}
output.write_string("\n")
}
if self.diagnostics.length() > 0 {
output.write_string("\n## Status report diagnostics\n\n")
for diagnostic in self.diagnostics {
output.write_string("- `")
output.write_string(diagnostic.code)
output.write_string("`: ")
output.write_string(diagnostic.message)
output.write_string("\n")
}
}
output.write_string("\n## Orphan statuses\n\n")
if self.orphan_status_ids.length() == 0 {
output.write_string("None.\n")
} else {
for id in self.orphan_status_ids {
output.write_string("- `")
output.write_string(id)
output.write_string("`\n")
}
}
output.to_string()
}