Setting the file. One moment.
Refund Corroboration · Rp Source Wordpress · wix/skills · Skills Docs
ContentsBack to the top of the page 39.41
PLUGIN READ PLANS
8 KB lib/ refund-corroboration.js
JavaScript · 194 lines · 8 KB
14
// amount, so it is the refund record itself rather than a hint that one exists. An earlier draft
15 // of this rule required a second, independently-produced signal even for those entities -- which
16 // would have refused every real refund entity a store carries, since both
17 // available core signals (`shop_order_refund` and the `refunded` order status) come from the same
18 // producer. Corroboration is required only when existence is being INFERRED from flags, statuses
19 // or gateway metadata; it is not required to believe a record that is itself the refund.
20 //
21 // This module reports the amount as the raw source string. Canonicalizing money is the target
22 // mapping's job (order-payment-mapping.js), so there is exactly one decimal implementation in the
23 // write path.
24
25 const fs = require ( 'node:fs' );
26 const path = require ( 'node:path' );
27
28 const REGISTRY_PATH = path. join (__dirname, 'refund-corroboration.json' );
29
30 function loadRegistry ( registryPath = REGISTRY_PATH ) {
31 return JSON . parse (fs. readFileSync (registryPath, 'utf8' ));
32 }
33
34 function trimmed ( value ) {
35 if (value === null || value === undefined ) return null ;
36 const text = String (value). trim ();
37 return text. length > 0 ? text : null ;
38 }
39
40 // Only enough parsing to decide qualification. The value handed on is the original string.
41 function positiveAmount ( raw ) {
42 const text = trimmed (raw);
43 if (text === null ) return null ;
44 if ( ! / ^ \+ ? \d + ( \. \d + ) ?$ / . test (text)) return null ;
45 return / [1-9] / . test (text) ? text : null ;
46 }
47
48 function excludedKeyFindings ( gatewayFields , registry ) {
49 const findings = [];
50 const names = Object. keys (gatewayFields || {});
51 for ( const rule of registry.excludedKeys) {
52 if (rule.key) {
53 if ( trimmed (gatewayFields[rule.key]) !== null ) {
54 findings. push ({ code: 'refund-weak-signal' , field: rule.key, exclusion: rule.exclusion });
55 }
56 continue ;
57 }
58 if (rule.keyPattern) {
59 const prefix = rule.keyPattern. replace ( / \* $ / , '' );
60 for ( const name of names) {
61 if (name. startsWith (prefix) && trimmed (gatewayFields[name]) !== null ) {
62 findings. push ({ code: 'refund-weak-signal' , field: name, exclusion: rule.exclusion });
63 }
64 }
65 }
66 }
67 return findings;
68 }
69
70 // `payplus_response` is declared unreadable rather than "to be pattern-matched later": it is a
71 // raw provider blob whose sibling fields carry customer name, phone and email. A
72 // refund contract that requires parsing unstructured PII is not one that can be honoured safely,
73 // so this function must never look inside it. Asserted by the test.
74 // Derived from the registry rather than duplicated here, so the declaration has one home.
75 function neverReadKeys ( registry = loadRegistry ()) {
76 return registry.excludedKeys
77 . filter (( rule ) => rule.exclusion === 'never-read' && rule.key)
78 . map (( rule ) => rule.key);
79 }
80
81 // A read plan that names an unreadable field is refused before it is executed, not filtered after
82 // the response comes back -- by then the blob is already in the run's working data.
83 function assertNoNeverReadKeys ( keys = [], registry = loadRegistry ()) {
84 const forbidden = new Set ( neverReadKeys (registry));
85 const violations = keys. filter (( key ) => forbidden. has (key));
86 if (violations. length > 0 ) {
87 throw new Error (
88 `these source keys are declared unreadable and must not appear in a read plan: ${ violations . join ( ', ' ) }` ,
89 );
90 }
91 return true ;
92 }
93
94 function qualifyRefund ({
95 refundEntity = null ,
96 orderStatus = null ,
97 gatewayFields = {},
98 registry = loadRegistry (),
99 } = {}) {
100 const findings = excludedKeyFindings (gatewayFields, registry);
101
102 for ( const key of neverReadKeys (registry)) {
103 if ( Object . prototype .hasOwnProperty. call (gatewayFields, key)) {
104 findings. push ({ code: 'redacted-source-values' , field: key, exclusion: 'never-read' });
105 }
106 }
107
108 const status = trimmed (orderStatus);
109 const statusCorroborates = status !== null && status. toLowerCase (). includes ( 'refunded' );
110
111 if (refundEntity) {
112 const sourceId = trimmed (refundEntity.id);
113 const amount = positiveAmount (refundEntity.amount);
114
115 if ( ! sourceId) {
116 findings. push ({ code: 'refund-weak-signal' , reason: 'refund entity has no stable source id' });
117 return { qualified: false , amount: null , sourceId: null , evidence: null , findings };
118 }
119 if ( ! amount) {
120 // An order-level `refunded` status can corroborate an entity but cannot supply its amount,
121 // and the order total is never a substitute.
122 findings. push ({ code: 'refund-weak-signal' , reason: 'refund entity has no parseable positive amount' , sourceId });
123 return { qualified: false , amount: null , sourceId, evidence: null , findings };
124 }
125
126 if (Array. isArray (refundEntity.lineItems) && refundEntity.lineItems. length > 0 ) {
127 findings. push ({ code: 'refund-line-detail-not-preserved' , sourceId, lines: refundEntity.lineItems. length });
128 }
129 findings. push ({ code: 'refund-date-not-preserved' , sourceId });
130
131 return {
132 qualified: true ,
133 amount,
134 sourceId,
135 evidence: 'woocommerce-refund-entity' ,
136 corroboratedByStatus: statusCorroborates,
137 findings,
138 };
139 }
140
141 if (statusCorroborates) {
142 findings. push ({ code: 'refund-weak-signal' , reason: 'refunded order status with no refund entity supplies no amount' , sourceStatus: status });
143 }
144
145 return { qualified: false , amount: null , sourceId: null , evidence: null , findings };
146 }
147
148 // Every source order gets one refund outcome, so a run can state "0 refunds found; N orders carry
149 // an uncorroborated gateway refund field" rather than reporting silence.
150 function summarizeRefundEvidence ( orders = [], registry = loadRegistry ()) {
151 const summary = { orders: orders. length , qualified: 0 , weakSignalOrders: 0 , findings: {} };
152 for ( const order of orders) {
153 const result = qualifyRefund ({ ... order, registry });
154 if (result.qualified) summary.qualified += 1 ;
155 if ( ! result.qualified && result.findings. some (( f ) => f.code === 'refund-weak-signal' )) {
156 summary.weakSignalOrders += 1 ;
157 }
158 for ( const finding of result.findings) {
159 summary.findings[finding.code] = (summary.findings[finding.code] || 0 ) + 1 ;
160 }
161 }
162 return summary;
163 }
164
165 // Occurrence numbers for a single order's qualified refunds.
166 //
167 // Target-side identity is the refund's facts plus WHICH occurrence of those facts this refund is,
168 // because two distinct source refunds can carry the same payment and amount. Nothing computed
169 // that number, so every caller got the default of 1 and the second identical refund reconciled
170 // against the first and was silently dropped. The importer writes one payment per order, so grouping
171 // by amount within the order is the same grouping the target reconciler uses.
172 //
173 // Ordering is by stable source id so a re-run assigns the same numbers to the same refunds.
174 function assignRefundOccurrences ( qualifiedRefunds = []) {
175 const seen = new Map ();
176 return [ ... qualifiedRefunds]
177 . sort (( a , b ) => String (a.sourceId). localeCompare ( String (b.sourceId)))
178 . map (( refund ) => {
179 const key = String (refund.amount);
180 const occurrence = (seen. get (key) || 0 ) + 1 ;
181 seen. set (key, occurrence);
182 return { ... refund, sourceOccurrence: occurrence };
183 });
184 }
185
186 module . exports = {
187 REGISTRY_PATH,
188 neverReadKeys,
189 assertNoNeverReadKeys,
190 loadRegistry,
191 qualifyRefund,
192 assignRefundOccurrences,
193 summarizeRefundEvidence,
194 };