Setting the file. One moment.
Payments Report · Rp Execution Policy · wix/skills · Skills Docs
ContentsBack to the top of the page lib/payments-report.js
lib/ payments-report.js
JavaScript · 244 lines · 12 KB
15 // field; a missing line is indistinguishable from a line nobody computed.
16 // 3. CANDIDATE COVERAGE IS NOT WRITABLE COVERAGE. Many orders carry some
17 // reference-shaped value while only a smaller share is currently defensible. Reporting the
18 // first number as though it were the second overstates fidelity, so the two are separate
19 // lines and the report refuses to collapse them.
20 //
21 // The denominator gets its own guard. A prior survey's narrower "card order" count must never
22 // stand in for the run's own reconciled read: the two can differ on a trading
23 // store, and the completeness claim is only as good as what it counted.
24
25 // The codes come from one frozen registry shared with the writers, not from a list kept here and
26 // not from scraping the writer source. A hand-maintained list drifted four times; the scraper that
27 // replaced it missed an outcome produced by a ternary, which is a green test over a real gap.
28 const registry = require ( '../../../lib/payments-outcome-registry.js' );
29
30 const PAYMENT_OUTCOMES = registry. PAYMENT_OUTCOMES ;
31 const ADDITIONAL_COUNTS = registry. REPORTABLE ;
32
33 // False for records, and therefore prohibited: changing payment provider does not lose payment
34 // history, because these records are provider-independent.
35 // Mirrors invoice-discovery's STRATEGIES. A store-wide strategy, never a per-document action.
36 const INVOICE_STRATEGIES = [ 'none-found' , 'number-only' , 'wordpress-hosted' , 'third-party-hosted' , 'mixed' ];
37
38 const PROHIBITED_PHRASES = [ 'lose your history' , 'lose their history' , 'loses your history' ];
39
40 // A count is a non-negative integer. Coercing anything else let a report with `sourceOrders: -1`
41 // and `payment-written: -1` come back approvable -- the sum check passes and the numbers are
42 // nonsense, which is worse than a missing line.
43 function asCount ( value , code , label , blocking ) {
44 if (value === undefined || value === null ) return 0 ;
45 const numeric = Number (value);
46 if ( ! Number. isInteger (numeric) || numeric < 0 ) {
47 if (blocking) {
48 blocking. push ({ code: code || 'invalid-count' , detail: `${ label } must be a non-negative integer, got ${ JSON . stringify ( value ) }` });
49 }
50 return 0 ;
51 }
52 return numeric;
53 }
54
55 function buildPaymentsReport ({
56 denominator = {},
57 outcomes = {},
58 counts = {},
59 referenceCoverage = {},
60 providers = null ,
61 providerParity = {},
62 invoiceStrategy = null ,
63 notes = [],
64 } = {}) {
65 const blocking = [];
66 const lines = [];
67
68 const sourceOrders = asCount (denominator.sourceOrders, 'invalid-denominator' , 'denominator.sourceOrders' , blocking);
69 const priorSurvey = denominator.priorSurveyCount === undefined || denominator.priorSurveyCount === null
70 ? null
71 : asCount (denominator.priorSurveyCount, 'invalid-count' , 'denominator.priorSurveyCount' , blocking);
72
73 if (denominator.from !== 'run-reconciled-read' ) {
74 blocking. push ({
75 code: 'denominator-not-from-reconciled-read' ,
76 detail: 'the denominator must come from this run \' s own reconciled source reads, not from a prior survey or a remembered constant' ,
77 });
78 }
79 // A stored count differing from a fresh one is NOT a defect. A store that is still trading
80 // takes new orders continuously, so two counts taken hours apart legitimately differ
81 // and no amount of reconciling makes them agree. Blocking on it would block every run on any
82 // live store. It is reported as drift, for context, and the run's own read is authoritative.
83 if (priorSurvey !== null && priorSurvey !== sourceOrders) {
84 lines. push ({
85 code: 'denominator-drift' ,
86 label: 'Change since the prior survey (expected on a trading store)' ,
87 value: sourceOrders - priorSurvey,
88 });
89 }
90
91 lines. push ({ code: 'source-orders' , label: 'Source orders discovered' , value: sourceOrders });
92 if (priorSurvey !== null ) {
93 lines. push ({ code: 'prior-survey-orders' , label: 'Prior survey count (not the denominator)' , value: priorSurvey });
94 }
95
96 let outcomeTotal = 0 ;
97 for ( const code of PAYMENT_OUTCOMES ) {
98 const value = asCount (outcomes[code], 'invalid-count' , `outcome ${ code }` , blocking);
99 outcomeTotal += value;
100 lines. push ({ code, label: code, value, partitioning: true });
101 }
102
103 // The buyer outcomes are a second partition. Optional as a whole (older runs report
104 // none), but once any is present they must account for every source order, or the report is
105 // hiding orders the same way a short payment partition would.
106 const buyerCodes = Object. keys (outcomes). filter (( code ) => registry. BUYER_OUTCOMES . includes (code));
107 if (buyerCodes. length > 0 ) {
108 let buyerTotal = 0 ;
109 for ( const code of registry. BUYER_OUTCOMES ) buyerTotal += asCount (outcomes[code], 'invalid-count' , `outcome ${ code }` , blocking);
110 if (buyerTotal !== sourceOrders) {
111 blocking. push ({ code: 'buyer-outcome-sum-mismatch' , detail: `the buyer outcomes sum to ${ buyerTotal } but the run discovered ${ sourceOrders } source orders` });
112 }
113 }
114 const unknownOutcomes = Object. keys (outcomes). filter (( code ) => ! PAYMENT_OUTCOMES . includes (code) && ! registry. BUYER_OUTCOMES . includes (code));
115 if (unknownOutcomes. length > 0 ) {
116 blocking. push ({
117 code: 'unknown-payment-outcome' ,
118 detail: `these outcomes are not declared as partitioning buckets, so the sum cannot be trusted: ${ unknownOutcomes . join ( ', ' ) }` ,
119 });
120 }
121 if (outcomeTotal !== sourceOrders) {
122 blocking. push ({
123 code: 'payment-outcome-sum-mismatch' ,
124 detail: `the payment outcomes sum to ${ outcomeTotal } but the run discovered ${ sourceOrders } source orders; ${ Math . abs ( sourceOrders - outcomeTotal ) } order(s) have no reported outcome` ,
125 });
126 }
127 lines. push ({ code: 'payment-outcome-total' , label: 'Payment outcomes total' , value: outcomeTotal });
128
129 // Both coverage numbers, always, and never one in place of the other.
130 const candidates = asCount (referenceCoverage.candidates, 'invalid-count' , 'referenceCoverage.candidates' , blocking);
131 const defensible = asCount (referenceCoverage.defensible, 'invalid-count' , 'referenceCoverage.defensible' , blocking);
132 lines. push ({ code: 'reference-candidates' , label: 'Orders carrying a reference-shaped candidate' , value: candidates });
133 lines. push ({ code: 'reference-defensible' , label: 'Orders with a defensible provider reference' , value: defensible });
134 lines. push ({ code: 'reference-not-defensible' , label: 'Orders whose candidate could not be attributed' , value: Math. max (candidates - defensible, 0 ) });
135 if (defensible > candidates) {
136 blocking. push ({
137 code: 'reference-coverage-impossible' ,
138 detail: `defensible references (${ defensible }) exceed candidates (${ candidates }), so one of the two was miscomputed` ,
139 });
140 }
141
142 for ( const code of ADDITIONAL_COUNTS ) {
143 // Buyer outcomes arrive under `outcomes` (they partition); everything else under `counts`.
144 const source = registry. BUYER_OUTCOMES . includes (code) ? outcomes : counts;
145 lines. push ({ code, label: code, value: asCount (source[code], 'invalid-count' , `count ${ code }` , blocking) });
146 }
147
148 // Same provenance rule as the invoice strategy: an absent inventory is missing discovery, not a
149 // store with no providers. Rendering "none found" for something nobody looked for is the exact
150 // confusion this report exists to prevent.
151 if (providers === null || providers === undefined ) {
152 blocking. push ({
153 code: 'provider-discovery-missing' ,
154 detail: 'no provider inventory was supplied; a store with genuinely no providers must pass an empty array' ,
155 });
156 }
157 const providerLines = (providers || []). map (( provider ) => ({
158 id: provider.id,
159 displayName: provider.displayName,
160 enabled: Boolean (provider.enabled),
161 }));
162 for ( const provider of providers || []) {
163 for ( const forbidden of [ 'secret' , 'key' , 'token' , 'password' , 'credential' ]) {
164 for ( const [ field , value ] of Object. entries (provider)) {
165 if (field. toLowerCase (). includes (forbidden) && value !== undefined && value !== null && value !== '' ) {
166 blocking. push ({
167 code: 'provider-inventory-carries-credential-shaped-field' ,
168 detail: `provider ${ provider . id } exposes "${ field }" in the report; the inventory carries identifier, display name and enabled state only` ,
169 });
170 }
171 }
172 }
173 }
174
175 const parity = {
176 country: providerParity.country || null ,
177 currency: providerParity.currency || null ,
178 supported: providerParity.supported || [],
179 confidence: providerParity.providerIdentityExposed === true ? 'verified-provider-parity' : 'unverified-provider-parity' ,
180 };
181
182 // A missing strategy is missing DISCOVERY, not an empty result -- reporting it as "none-found"
183 // makes "we looked and there is nothing" indistinguishable from "we never looked". And the
184 // value must be one of the strategies, not an action: `reference-only` is what happens to one
185 // document, and it passed silently as a store-wide strategy.
186 const strategy = invoiceStrategy === null || invoiceStrategy === undefined ? null : String (invoiceStrategy);
187 if (strategy === null ) {
188 blocking. push ({ code: 'invoice-discovery-missing' , detail: 'no invoice strategy was reported; discovery must run before the plan is approved' });
189 } else if ( ! INVOICE_STRATEGIES . includes (strategy)) {
190 blocking. push ({ code: 'invalid-invoice-strategy' , detail: `"${ strategy }" is not an invoice strategy; expected one of ${ INVOICE_STRATEGIES . join ( ', ' ) }` });
191 }
192 lines. push ({ code: 'invoice-strategy' , label: 'Invoice strategy' , value: strategy === null ? 'not-reported' : strategy });
193
194 const rendered = renderPaymentsReport ({ lines, providers: providerLines, parity, notes });
195 const lowered = rendered. toLowerCase ();
196 for ( const phrase of PROHIBITED_PHRASES ) {
197 if (lowered. includes (phrase)) {
198 blocking. push ({
199 code: 'prohibited-phrase' ,
200 detail: `the report contains "${ phrase }", which is false for payment records: they are provider-independent and survive a provider change` ,
201 });
202 }
203 }
204
205 return { lines, providers: providerLines, parity, blocking, rendered, approvable: blocking. length === 0 };
206 }
207
208 function renderPaymentsReport ({ lines , providers , parity , notes = [] }) {
209 const out = [ 'Payments, refunds and invoice documents' , '' ];
210
211 out. push ( 'Source payment providers found:' );
212 if (providers. length === 0 ) {
213 out. push ( ' none found' );
214 } else {
215 for ( const provider of providers) {
216 out. push ( ` ${ provider . id } — ${ provider . displayName } — ${ provider . enabled ? 'enabled' : 'disabled'}` );
217 }
218 }
219 out. push ( ' no credential value was read or retained' );
220 out. push ( '' );
221
222 out. push ( `Provider availability for ${ parity . country || 'unknown country'} / ${ parity . currency || 'unknown currency'}: ${ parity . confidence }` );
223 if (parity.supported. length > 0 ) out. push ( ` supported: ${ parity . supported . join ( ', ' ) }` );
224 out. push ( '' );
225
226 for ( const line of lines) {
227 out. push ( ` ${ line . label }: ${ line . value }` );
228 }
229
230 if (notes. length > 0 ) {
231 out. push ( '' );
232 for ( const note of notes) out. push ( ` note: ${ note }` );
233 }
234 return out. join ( ' \n ' );
235 }
236
237 module . exports = {
238 PAYMENT_OUTCOMES,
239 ADDITIONAL_COUNTS,
240 PROHIBITED_PHRASES,
241 INVOICE_STRATEGIES,
242 buildPaymentsReport,
243 renderPaymentsReport,
244 };