@12-apps/payments-frontend 3.17.0 → 3.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/activation/public.ts +90 -0
- package/src/activation/screens/activation-step.tsx +168 -0
- package/src/activation/screens/awaiting-payment.tsx +227 -0
- package/src/activation/screens/card-verification.tsx +182 -0
- package/src/activation/screens/copy-context.tsx +43 -0
- package/src/activation/screens/copy.ts +150 -0
- package/src/activation/screens/index.ts +10 -0
- package/src/activation/screens/notice.tsx +111 -0
- package/src/activation/screens/redirect-verification.tsx +249 -0
- package/src/activation/screens/states.tsx +244 -0
- package/src/activation/screens/use-verification-amount.ts +42 -0
- package/src/index.ts +27 -49
- package/src/ledger/index.ts +17 -0
- package/src/ledger/query.ts +81 -0
- package/src/ledger/rows.ts +85 -0
- package/src/ledger/wire.ts +58 -0
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Button, Stack } from '@mui/material';
|
|
4
|
+
import type { JSX } from 'react';
|
|
5
|
+
|
|
6
|
+
import { BTN_PRIMARY_SX, BTN_SECONDARY_SX, LINKISH_SX } from '../../components/panel-tokens';
|
|
7
|
+
|
|
8
|
+
import { useActivationCopy } from './copy-context';
|
|
9
|
+
import { Notice, ProviderMessage } from './notice';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The settled outcomes of the activation charge, shared by both flows.
|
|
13
|
+
*
|
|
14
|
+
* They live apart from the panels that frame them because the panel is about
|
|
15
|
+
* layout and these are about what the owner is being TOLD — and every sentence
|
|
16
|
+
* here has cost real money to get right more than once. Three of the six are
|
|
17
|
+
* ways the link never got minted, and they are three screens rather than one
|
|
18
|
+
* because they are three different instructions: fix a setting, wait it out,
|
|
19
|
+
* read the provider's own words.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Settled-and-passed: the provider is on, and the cent is on its way back. */
|
|
23
|
+
export function PassedState({
|
|
24
|
+
amountLabel,
|
|
25
|
+
refunded,
|
|
26
|
+
onRetry,
|
|
27
|
+
}: {
|
|
28
|
+
/** What was charged. `null` only while the endpoint has not priced it yet. */
|
|
29
|
+
amountLabel: string | null;
|
|
30
|
+
refunded: boolean;
|
|
31
|
+
onRetry: () => void;
|
|
32
|
+
}): JSX.Element {
|
|
33
|
+
const { outcome, actions } = useActivationCopy();
|
|
34
|
+
const amount = amountLabel ?? outcome.someAmount;
|
|
35
|
+
return (
|
|
36
|
+
<Stack spacing={1} data-testid="verify-charge-passed">
|
|
37
|
+
<Notice
|
|
38
|
+
tone="ok"
|
|
39
|
+
title={outcome.approvedTitle}
|
|
40
|
+
description={refunded ? outcome.refundedBody(amount) : outcome.refundPendingBody(amount)}
|
|
41
|
+
/>
|
|
42
|
+
<Stack direction="row">
|
|
43
|
+
<Button sx={LINKISH_SX} onClick={onRetry} data-testid="verify-charge-retry">
|
|
44
|
+
{actions.testAgain}
|
|
45
|
+
</Button>
|
|
46
|
+
</Stack>
|
|
47
|
+
</Stack>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Settled-and-refused. The wording is the point: the owner IS connected, so
|
|
53
|
+
* telling them the connection failed would send them to reauthorize something
|
|
54
|
+
* that already works.
|
|
55
|
+
*/
|
|
56
|
+
export function FailedState({
|
|
57
|
+
reason,
|
|
58
|
+
providerMessage,
|
|
59
|
+
onRetry,
|
|
60
|
+
}: {
|
|
61
|
+
reason: string;
|
|
62
|
+
providerMessage?: string;
|
|
63
|
+
onRetry: () => void;
|
|
64
|
+
}): JSX.Element {
|
|
65
|
+
const { outcome, actions } = useActivationCopy();
|
|
66
|
+
return (
|
|
67
|
+
<Stack spacing={1} data-testid="verify-charge-failed">
|
|
68
|
+
<Notice tone="bad" title={outcome.authenticatedNotActive} description={reason} />
|
|
69
|
+
{providerMessage ? (
|
|
70
|
+
<ProviderMessage message={providerMessage} label={outcome.providerSaid} />
|
|
71
|
+
) : null}
|
|
72
|
+
<Stack direction="row">
|
|
73
|
+
<Button sx={BTN_PRIMARY_SX} onClick={onRetry} data-testid="verify-charge-retry">
|
|
74
|
+
{actions.retry}
|
|
75
|
+
</Button>
|
|
76
|
+
</Stack>
|
|
77
|
+
</Stack>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* The provider would not CREATE the link. A different failure with a different
|
|
83
|
+
* owner, and it had been wearing the wrong one's clothes.
|
|
84
|
+
*
|
|
85
|
+
* Nothing was charged and nothing is outstanding — the request to mint a
|
|
86
|
+
* payment page was refused outright, which overwhelmingly means a provider-side
|
|
87
|
+
* switch is still off. That is a setup step rather than an error, and the
|
|
88
|
+
* screen it replaced ("authenticated but not active", with a retry button) sent
|
|
89
|
+
* the owner to re-check credentials that were fine and then to press retry
|
|
90
|
+
* against a setting that had not changed.
|
|
91
|
+
*
|
|
92
|
+
* `onDismiss` is not "try the same thing again": it clears the failed attempt
|
|
93
|
+
* and returns the step to its starting state, which is what the owner needs
|
|
94
|
+
* AFTER going to flip the setting. Safe here in a way it is not elsewhere in
|
|
95
|
+
* this flow, because nothing was charged — there is no outstanding payment for
|
|
96
|
+
* a second attempt to duplicate.
|
|
97
|
+
*/
|
|
98
|
+
export function SetupIncompleteState({
|
|
99
|
+
displayName,
|
|
100
|
+
reason,
|
|
101
|
+
providerMessage,
|
|
102
|
+
onDismiss,
|
|
103
|
+
}: {
|
|
104
|
+
displayName: string;
|
|
105
|
+
reason: string;
|
|
106
|
+
providerMessage?: string;
|
|
107
|
+
onDismiss: () => void;
|
|
108
|
+
}): JSX.Element {
|
|
109
|
+
const { outcome, actions } = useActivationCopy();
|
|
110
|
+
return (
|
|
111
|
+
<Stack spacing={1} data-testid="verify-charge-setup-incomplete">
|
|
112
|
+
<Notice
|
|
113
|
+
tone="warn"
|
|
114
|
+
title={outcome.refusedTitle(displayName)}
|
|
115
|
+
description={outcome.refusedBody(displayName)}
|
|
116
|
+
/>
|
|
117
|
+
{reason ? <Notice tone="info" title={reason} /> : null}
|
|
118
|
+
{providerMessage ? (
|
|
119
|
+
<ProviderMessage message={providerMessage} label={outcome.providerSaid} />
|
|
120
|
+
) : null}
|
|
121
|
+
<Stack direction="row">
|
|
122
|
+
<Button sx={LINKISH_SX} onClick={onDismiss} data-testid="verify-charge-retry">
|
|
123
|
+
{actions.restart}
|
|
124
|
+
</Button>
|
|
125
|
+
</Stack>
|
|
126
|
+
</Stack>
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Already proven: the ONLY honest thing this step can render is that fact.
|
|
132
|
+
*
|
|
133
|
+
* It used to reset to the pay button on every reload, because the proof lives
|
|
134
|
+
* on the server and the screen never asked. An owner whose charge HAD landed
|
|
135
|
+
* was greeted by the pay button again — and one owner, reasonably reading that
|
|
136
|
+
* as "it did not work", paid four times.
|
|
137
|
+
*
|
|
138
|
+
* It is also the end of the flow, so it says where to go next. A terminal
|
|
139
|
+
* screen with no exit is how an owner who has just finished setting up payments
|
|
140
|
+
* ends up hunting the sidebar for the two things they now actually want: the
|
|
141
|
+
* order providers are tried in, and the storefront this was all for. Both sit
|
|
142
|
+
* INSIDE the panel, because they are what this outcome offers.
|
|
143
|
+
*/
|
|
144
|
+
export function ProvenState({
|
|
145
|
+
storeUrl,
|
|
146
|
+
onProviderOrder,
|
|
147
|
+
}: {
|
|
148
|
+
/** The storefront this connection now takes money for. */
|
|
149
|
+
storeUrl: string;
|
|
150
|
+
/** Back to the provider list, which is where the failover chain lives. */
|
|
151
|
+
onProviderOrder: () => void;
|
|
152
|
+
}): JSX.Element {
|
|
153
|
+
const { outcome, actions } = useActivationCopy();
|
|
154
|
+
return (
|
|
155
|
+
<Notice
|
|
156
|
+
tone="ok"
|
|
157
|
+
title={outcome.provenTitle}
|
|
158
|
+
description={outcome.provenBody}
|
|
159
|
+
dataTestId="verify-charge-proven"
|
|
160
|
+
>
|
|
161
|
+
<Stack direction="row" spacing={1} sx={{ flexWrap: 'wrap' }}>
|
|
162
|
+
<Button
|
|
163
|
+
sx={BTN_SECONDARY_SX}
|
|
164
|
+
onClick={onProviderOrder}
|
|
165
|
+
data-testid="verify-charge-provider-order"
|
|
166
|
+
>
|
|
167
|
+
{actions.setProviderOrder}
|
|
168
|
+
</Button>
|
|
169
|
+
<Button
|
|
170
|
+
sx={BTN_SECONDARY_SX}
|
|
171
|
+
onClick={() => window.open(storeUrl, '_blank', 'noopener')}
|
|
172
|
+
data-testid="verify-charge-open-store"
|
|
173
|
+
>
|
|
174
|
+
{actions.seePublishedStore}
|
|
175
|
+
</Button>
|
|
176
|
+
</Stack>
|
|
177
|
+
</Notice>
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* We could not reach the provider while MINTING the link.
|
|
183
|
+
*
|
|
184
|
+
* Deliberately not {@link SetupIncompleteState}, and the difference is the whole
|
|
185
|
+
* reason this exists: that screen tells the owner a provider-side switch is
|
|
186
|
+
* probably off and puts them back a step to fix it. A request that never
|
|
187
|
+
* arrived is no evidence of that at all — the provider refused nothing — so
|
|
188
|
+
* saying it would send someone whose connection blinked to change a setting
|
|
189
|
+
* that was already correct, and take a finished step away from them on the way.
|
|
190
|
+
*
|
|
191
|
+
* The only honest instruction is the one an outage deserves: try again.
|
|
192
|
+
*/
|
|
193
|
+
export function UnreachableState({
|
|
194
|
+
reason,
|
|
195
|
+
providerMessage,
|
|
196
|
+
onRetry,
|
|
197
|
+
}: {
|
|
198
|
+
reason: string;
|
|
199
|
+
providerMessage?: string;
|
|
200
|
+
onRetry: () => void;
|
|
201
|
+
}): JSX.Element {
|
|
202
|
+
const { outcome, actions } = useActivationCopy();
|
|
203
|
+
return (
|
|
204
|
+
<Stack spacing={1} data-testid="verify-charge-unreachable">
|
|
205
|
+
<Notice tone="warn" title={outcome.unreachableTitle} description={reason} />
|
|
206
|
+
{providerMessage ? (
|
|
207
|
+
<ProviderMessage message={providerMessage} label={outcome.providerSaid} />
|
|
208
|
+
) : null}
|
|
209
|
+
<Stack direction="row">
|
|
210
|
+
<Button sx={BTN_PRIMARY_SX} onClick={onRetry} data-testid="verify-charge-retry-create">
|
|
211
|
+
{actions.tryAgain}
|
|
212
|
+
</Button>
|
|
213
|
+
</Stack>
|
|
214
|
+
</Stack>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* The link's window elapsed with nobody paying it.
|
|
220
|
+
*
|
|
221
|
+
* Nothing failed and nothing was charged, so the wording carries no blame and
|
|
222
|
+
* the offer is the only thing that helps: another link. The sentence it
|
|
223
|
+
* replaced read as a refusal, and an owner who believes a charge was refused
|
|
224
|
+
* does not press a button that looks like it charges again.
|
|
225
|
+
*/
|
|
226
|
+
export function ExpiredState({
|
|
227
|
+
reason,
|
|
228
|
+
onRegenerate,
|
|
229
|
+
}: {
|
|
230
|
+
reason: string;
|
|
231
|
+
onRegenerate: () => void;
|
|
232
|
+
}): JSX.Element {
|
|
233
|
+
const { outcome, actions } = useActivationCopy();
|
|
234
|
+
return (
|
|
235
|
+
<Stack spacing={1} data-testid="verify-charge-expired">
|
|
236
|
+
<Notice tone="info" title={outcome.expiredTitle} description={reason} />
|
|
237
|
+
<Stack direction="row">
|
|
238
|
+
<Button sx={BTN_PRIMARY_SX} onClick={onRegenerate} data-testid="verify-charge-regenerate">
|
|
239
|
+
{actions.generateNewCharge}
|
|
240
|
+
</Button>
|
|
241
|
+
</Stack>
|
|
242
|
+
</Stack>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the activation charge will actually cost, per provider.
|
|
7
|
+
*
|
|
8
|
+
* NOT always a cent. At least one provider refuses a one-cent total outright —
|
|
9
|
+
* InfinitePay answers `422 {"errors":{"items":["Total price must be greater
|
|
10
|
+
* than 1"]}}`, where the "1" is one REAL — so its verification charges more,
|
|
11
|
+
* and a button promising one figure while charging another would be exactly the
|
|
12
|
+
* kind of lie this flow exists to remove.
|
|
13
|
+
*
|
|
14
|
+
* Read from the host's endpoint rather than assumed here: the minimum is a fact
|
|
15
|
+
* about the provider's API, discovered from its own refusal, and a constant in
|
|
16
|
+
* this package would be wrong for the first adopter whose provider disagrees.
|
|
17
|
+
*
|
|
18
|
+
* `null` until it answers — the caller decides what a sentence says before
|
|
19
|
+
* there is an amount to name, because that is a sentence and sentences are the
|
|
20
|
+
* host's. The CARD flow does not need this at all: `useActivationCharge` reads
|
|
21
|
+
* the amount out of the same body it reads the store's card key from.
|
|
22
|
+
*/
|
|
23
|
+
export function useVerificationAmount(verifyChargeUrl: string): number | null {
|
|
24
|
+
const [amountCents, setAmountCents] = useState<number | null>(null);
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
let active = true;
|
|
28
|
+
void fetch(verifyChargeUrl)
|
|
29
|
+
.then((res) => (res.ok ? (res.json() as Promise<{ amountCents?: number }>) : null))
|
|
30
|
+
.then((body) => {
|
|
31
|
+
if (active && typeof body?.amountCents === 'number') setAmountCents(body.amountCents);
|
|
32
|
+
})
|
|
33
|
+
// A failed read leaves the amount unnamed rather than guessed — the
|
|
34
|
+
// screen still renders, and it is corrected the moment an answer lands.
|
|
35
|
+
.catch(() => undefined);
|
|
36
|
+
return () => {
|
|
37
|
+
active = false;
|
|
38
|
+
};
|
|
39
|
+
}, [verifyChargeUrl]);
|
|
40
|
+
|
|
41
|
+
return amountCents;
|
|
42
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -334,37 +334,35 @@ export { PT_BR_PLATFORM_HOMOLOGACAO_COPY } from './components/platform/pt-BR';
|
|
|
334
334
|
export type { PaymentEnvironment } from '@12-apps/payments-backend';
|
|
335
335
|
|
|
336
336
|
// ---------------------------------------------------------------------------
|
|
337
|
-
// The
|
|
338
|
-
//
|
|
337
|
+
// The ACTIVATION STEP (FUT-463, FUT-763, FUT-764) — proving a connection can
|
|
338
|
+
// actually charge, both protocols and the screens that render them.
|
|
339
339
|
//
|
|
340
|
-
//
|
|
341
|
-
//
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
//
|
|
340
|
+
// A connection is not a capability: a completed grant says the owner authorized
|
|
341
|
+
// us, not that the account can take money. The owner's own card — or a real
|
|
342
|
+
// link they pay on the provider's page — goes through the SAME path a shopper's
|
|
343
|
+
// does, for a small amount, refunded or landing in their own account.
|
|
344
|
+
//
|
|
345
|
+
// Every branch in there was learned from a payment that went wrong: an owner
|
|
346
|
+
// who paid four times, a dead end blaming a store for a key that was never
|
|
347
|
+
// going to exist, a refusal wearing another failure's clothes. The SENTENCES
|
|
348
|
+
// stay the host's, required and defaultless, as everywhere here.
|
|
349
|
+
//
|
|
350
|
+
// Listed in `./activation/public` — see its header for why it is not inline.
|
|
345
351
|
// ---------------------------------------------------------------------------
|
|
346
|
-
export
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
type RedirectActivationState,
|
|
361
|
-
type SettlePollIo,
|
|
362
|
-
} from './activation/redirect-state';
|
|
363
|
-
export {
|
|
364
|
-
clearReturnedSettlement,
|
|
365
|
-
takeReturnedSettlement,
|
|
366
|
-
RETURNED_SETTLEMENT_KEY,
|
|
367
|
-
} from './activation/returned-settlement';
|
|
352
|
+
export * from './activation/public';
|
|
353
|
+
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
// The two payment LEDGERS (FUT-764) — every charge raised against every order,
|
|
356
|
+
// and the subset where the provider captured LESS than the order was worth.
|
|
357
|
+
//
|
|
358
|
+
// UI-free on purpose. There is no component in that folder: a ledger is a
|
|
359
|
+
// table, every host already has one, and a grid slot wide enough to satisfy
|
|
360
|
+
// them all would be a worse contract than handing over rows. What moves is
|
|
361
|
+
// what a host was deriving twice and getting subtly wrong both times — which
|
|
362
|
+
// amount is "captured" when the audit diff and the payment row disagree, why a
|
|
363
|
+
// decision is shown INSTEAD of the order status, and what still counts as work.
|
|
364
|
+
// ---------------------------------------------------------------------------
|
|
365
|
+
export * from './ledger';
|
|
368
366
|
|
|
369
367
|
// ---------------------------------------------------------------------------
|
|
370
368
|
// The connect ROUND TRIP's other end (FUT-763): what the OAuth callback
|
|
@@ -378,23 +376,3 @@ export {
|
|
|
378
376
|
type ConnectErrorCode,
|
|
379
377
|
type ConnectReturn,
|
|
380
378
|
} from './components/connect-return';
|
|
381
|
-
|
|
382
|
-
// ---------------------------------------------------------------------------
|
|
383
|
-
// The ACTIVATION CHARGE (FUT-463, packaged by FUT-763) — proving a connection
|
|
384
|
-
// can charge, for a provider whose payer pays HERE.
|
|
385
|
-
//
|
|
386
|
-
// A connection is not a capability: a completed grant says the owner authorized
|
|
387
|
-
// us, not that the account can take money. The owner's own card goes through
|
|
388
|
-
// the SAME path a shopper's does — same fields, same validation, same
|
|
389
|
-
// browser-side encryption — for one cent, refunded immediately.
|
|
390
|
-
//
|
|
391
|
-
// The sibling of `useRedirectActivation` for the other half of the same step.
|
|
392
|
-
// As there, the SCREEN stays the host's.
|
|
393
|
-
// ---------------------------------------------------------------------------
|
|
394
|
-
export {
|
|
395
|
-
useActivationCharge,
|
|
396
|
-
type ActivationCharge,
|
|
397
|
-
type ActivationChargeOptions,
|
|
398
|
-
type ActivationChargeState,
|
|
399
|
-
} from './activation/use-activation-charge';
|
|
400
|
-
export { type ActivationChargeCopy } from './activation/charge-copy';
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export {
|
|
2
|
+
toShortPaymentRows,
|
|
3
|
+
type ShortPaymentOutcomeCopy,
|
|
4
|
+
type ShortPaymentRow,
|
|
5
|
+
} from './rows';
|
|
6
|
+
export {
|
|
7
|
+
ledgerParams,
|
|
8
|
+
ledgerSearch,
|
|
9
|
+
ledgerSearchTerm,
|
|
10
|
+
ledgerSort,
|
|
11
|
+
type LedgerSort,
|
|
12
|
+
} from './query';
|
|
13
|
+
export {
|
|
14
|
+
type LedgerFormatters,
|
|
15
|
+
type PaymentLedgerWire,
|
|
16
|
+
type ShortPaymentWire,
|
|
17
|
+
} from './wire';
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The URL a ledger page is bookmarkable at, and the request it becomes.
|
|
3
|
+
*
|
|
4
|
+
* A server-driven list keeps its search, its sort and its page in the address
|
|
5
|
+
* bar so a link an operator sends to a colleague opens the same rows. Both
|
|
6
|
+
* ledgers do it identically, and both got the same two things subtly wrong
|
|
7
|
+
* when written twice:
|
|
8
|
+
*
|
|
9
|
+
* - a facet value from a stale bookmark forwarded straight into a 400, which
|
|
10
|
+
* replaces the WHOLE page — grid, selector and all — with an error whose only
|
|
11
|
+
* affordance re-requests the same bad URL;
|
|
12
|
+
* - the default facet included in the query, so the react-query key differed
|
|
13
|
+
* between an operator who arrived with `?view=all` and one who arrived with
|
|
14
|
+
* nothing, and the identical list was fetched twice and cached apart.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The params a ledger grid owns and forwards verbatim. */
|
|
18
|
+
const OWNED = ['q', 'page', 'sort'] as const;
|
|
19
|
+
|
|
20
|
+
/** A sort as the wire spells it, or nothing when the URL carries none. */
|
|
21
|
+
export interface LedgerSort {
|
|
22
|
+
id: string;
|
|
23
|
+
dir: 'asc' | 'desc';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The query string for the current URL.
|
|
28
|
+
*
|
|
29
|
+
* `facet` is passed already SANITIZED — a caller resolves an unknown value to
|
|
30
|
+
* its default first — and omitted when it IS the default, so the request and
|
|
31
|
+
* the cache key are identical however the operator arrived.
|
|
32
|
+
*/
|
|
33
|
+
export function ledgerSearch(
|
|
34
|
+
params: URLSearchParams,
|
|
35
|
+
facet?: { key: string; value: string; fallback: string },
|
|
36
|
+
): string {
|
|
37
|
+
const query = new URLSearchParams();
|
|
38
|
+
for (const key of OWNED) {
|
|
39
|
+
const value = params.get(key);
|
|
40
|
+
if (value) query.set(key, value);
|
|
41
|
+
}
|
|
42
|
+
if (facet && facet.value !== facet.fallback) query.set(facet.key, facet.value);
|
|
43
|
+
return query.toString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The sort the URL is carrying, if it is carrying a well-formed one. */
|
|
47
|
+
export function ledgerSort(params: URLSearchParams): LedgerSort[] {
|
|
48
|
+
const [id, dir] = (params.get('sort') ?? '').split(':');
|
|
49
|
+
if (!id || !dir) return [];
|
|
50
|
+
return [{ id, dir: dir === 'desc' ? 'desc' : 'asc' }];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The search term the URL is carrying. */
|
|
54
|
+
export function ledgerSearchTerm(params: URLSearchParams): string {
|
|
55
|
+
return params.get('q') ?? '';
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* A grid query mapped back onto the params a ledger URL owns.
|
|
60
|
+
*
|
|
61
|
+
* `dir` accepts `null` as well as absent, because that is what a real grid
|
|
62
|
+
* produces: a column can be in the sort list with no direction chosen yet, and
|
|
63
|
+
* a data-grid library spells that `null` rather than by leaving the key out.
|
|
64
|
+
* The runtime always treated the two the same — an unsorted column contributes
|
|
65
|
+
* no `sort` param — so this is the TYPE catching up with the behaviour rather
|
|
66
|
+
* than a change to it. Typed the other way, every adopter writes the same
|
|
67
|
+
* `?? undefined` mapping at the call site, which is exactly the boilerplate
|
|
68
|
+
* this module exists to delete.
|
|
69
|
+
*/
|
|
70
|
+
export function ledgerParams(query: {
|
|
71
|
+
search: string;
|
|
72
|
+
page: number;
|
|
73
|
+
sortBy: readonly { id: string; dir?: 'asc' | 'desc' | null }[];
|
|
74
|
+
}): Record<string, string | undefined> {
|
|
75
|
+
const sort = query.sortBy[0];
|
|
76
|
+
return {
|
|
77
|
+
q: query.search || undefined,
|
|
78
|
+
page: query.page > 1 ? String(query.page) : undefined,
|
|
79
|
+
sort: sort?.dir ? `${sort.id}:${sort.dir}` : undefined,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import type { LedgerFormatters, ShortPaymentWire } from './wire';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The short-payment queue's display projection.
|
|
5
|
+
*
|
|
6
|
+
* Three of these five decisions are the reason this is not a `map` a host
|
|
7
|
+
* writes for itself, and each one has been wrong in production:
|
|
8
|
+
*
|
|
9
|
+
* - **Which amount is "captured".** The audit diff can be missing it, and the
|
|
10
|
+
* payment row beside it is the same money seen by a second witness. Reading
|
|
11
|
+
* only the diff printed a dash where real money had landed.
|
|
12
|
+
* - **What the row's SITUATION is.** An operator's decision is shown INSTEAD of
|
|
13
|
+
* the order status, not beside it: a refunded shortfall leaves the order
|
|
14
|
+
* FAILED forever, so reading the status alone keeps calling finished work
|
|
15
|
+
* unreconciled — the same reason a sidebar badge has to subtract decisions.
|
|
16
|
+
* - **Whether the row is still WORK.** `pending` is what the row actions hang
|
|
17
|
+
* off, and a settled order with no decision recorded is not work either.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/** A wire row shaped for a table. Labels are formatted; `pending` is a fact. */
|
|
21
|
+
export interface ShortPaymentRow extends Record<string, unknown> {
|
|
22
|
+
id: string;
|
|
23
|
+
detectedAtLabel: string;
|
|
24
|
+
orderId: string;
|
|
25
|
+
expectedLabel: string;
|
|
26
|
+
capturedLabel: string;
|
|
27
|
+
shortfallLabel: string;
|
|
28
|
+
outcomeLabel: string;
|
|
29
|
+
methodLabel: string;
|
|
30
|
+
chargeLabel: string;
|
|
31
|
+
/** Drives the row actions: a decided shortfall offers none. */
|
|
32
|
+
pending: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What the SITUATION column can say, in the host's words.
|
|
37
|
+
*
|
|
38
|
+
* Two tables rather than one, because they answer different questions: what an
|
|
39
|
+
* operator DECIDED, and — when nobody has — what the order's status implies. A
|
|
40
|
+
* status or resolution with no entry falls through as its own raw value, which
|
|
41
|
+
* is the honest answer for a state this package has not met.
|
|
42
|
+
*/
|
|
43
|
+
export interface ShortPaymentOutcomeCopy {
|
|
44
|
+
/** Keyed by `resolution` — `SETTLED`, `REFUNDED`. */
|
|
45
|
+
readonly resolution: Readonly<Record<string, string>>;
|
|
46
|
+
/** Keyed by `orderStatus`, for a row nobody has decided yet. */
|
|
47
|
+
readonly orderStatus: Readonly<Record<string, string>>;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The decision if there is one, else what the order's status implies. */
|
|
51
|
+
function outcomeLabel(
|
|
52
|
+
entry: ShortPaymentWire,
|
|
53
|
+
copy: ShortPaymentOutcomeCopy,
|
|
54
|
+
placeholder: string,
|
|
55
|
+
): string {
|
|
56
|
+
if (entry.resolution) return copy.resolution[entry.resolution] ?? entry.resolution;
|
|
57
|
+
if (!entry.orderStatus) return placeholder;
|
|
58
|
+
return copy.orderStatus[entry.orderStatus] ?? entry.orderStatus;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function toShortPaymentRows(
|
|
62
|
+
entries: ShortPaymentWire[],
|
|
63
|
+
format: LedgerFormatters,
|
|
64
|
+
copy: ShortPaymentOutcomeCopy,
|
|
65
|
+
): ShortPaymentRow[] {
|
|
66
|
+
const money = (cents: number | null): string =>
|
|
67
|
+
cents === null ? format.placeholder : format.amount(cents);
|
|
68
|
+
|
|
69
|
+
return entries.map((entry) => ({
|
|
70
|
+
id: entry.id,
|
|
71
|
+
detectedAtLabel: format.dateTime(entry.detectedAt),
|
|
72
|
+
orderId: entry.orderId,
|
|
73
|
+
expectedLabel: money(entry.expectedCents),
|
|
74
|
+
// Same money, two witnesses: prefer the audit diff, fall back to the
|
|
75
|
+
// payment row it was joined to. Reading only the diff printed a dash over
|
|
76
|
+
// a capture that had really happened.
|
|
77
|
+
capturedLabel: money(entry.capturedCents ?? entry.payment?.amountCents ?? null),
|
|
78
|
+
shortfallLabel: money(entry.shortfallCents),
|
|
79
|
+
outcomeLabel: outcomeLabel(entry, copy, format.placeholder),
|
|
80
|
+
methodLabel: entry.method ?? format.placeholder,
|
|
81
|
+
chargeLabel: entry.providerChargeId ?? format.placeholder,
|
|
82
|
+
// Still work: nobody decided, and the order did not settle by itself.
|
|
83
|
+
pending: entry.resolution === null && entry.orderStatus !== 'PAID',
|
|
84
|
+
}));
|
|
85
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The two payment LEDGERS a store operator reads (FUT-764 burn-down).
|
|
3
|
+
*
|
|
4
|
+
* Every charge raised against every order, and the subset where the provider
|
|
5
|
+
* captured LESS than the order was worth. Both are server-driven lists over
|
|
6
|
+
* rows this package's own tables produce, and every host that mounts the
|
|
7
|
+
* payments platform has both — which is why the projection, the query mapping
|
|
8
|
+
* and the one write live here rather than being derived again per adopter.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately UI-FREE. There is no component in this folder and no design
|
|
11
|
+
* system anywhere near it: a ledger is a table, every host already has one, and
|
|
12
|
+
* a grid slot wide enough to satisfy them all would be a worse contract than
|
|
13
|
+
* handing over rows. What a host renders is its own; WHAT IT MEANS is here.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** One row of the store's payment ledger, as the API answers it. */
|
|
17
|
+
export interface PaymentLedgerWire {
|
|
18
|
+
id: string;
|
|
19
|
+
createdAt: string;
|
|
20
|
+
orderId: string | null;
|
|
21
|
+
status: string;
|
|
22
|
+
provider: string | null;
|
|
23
|
+
method: string | null;
|
|
24
|
+
amountCents: number;
|
|
25
|
+
/** What the order was worth, when this charge fell short of it. */
|
|
26
|
+
expectedCents?: number | null;
|
|
27
|
+
/** The gap, present only on a short capture. */
|
|
28
|
+
shortfallCents?: number | null;
|
|
29
|
+
providerChargeId: string | null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** One row of the short-payment reconciliation queue, as the API answers it. */
|
|
33
|
+
export interface ShortPaymentWire {
|
|
34
|
+
id: string;
|
|
35
|
+
detectedAt: string;
|
|
36
|
+
orderId: string;
|
|
37
|
+
orderStatus: string | null;
|
|
38
|
+
capturedCents: number | null;
|
|
39
|
+
expectedCents: number | null;
|
|
40
|
+
shortfallCents: number | null;
|
|
41
|
+
method: string | null;
|
|
42
|
+
providerChargeId: string | null;
|
|
43
|
+
/** The payment row the money actually landed on — the second witness. */
|
|
44
|
+
payment: { id: string; status: string; amountCents: number; createdAt: string } | null;
|
|
45
|
+
/** `SETTLED` / `REFUNDED` once an operator decided; null while waiting. */
|
|
46
|
+
resolution: string | null;
|
|
47
|
+
resolvedAt: string | null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** How a host writes the two things a ledger row is made of. */
|
|
51
|
+
export interface LedgerFormatters {
|
|
52
|
+
/** A cents amount, in the operator's language and this product's currency. */
|
|
53
|
+
amount: (cents: number) => string;
|
|
54
|
+
/** A wire timestamp, as the operator reads dates elsewhere in the console. */
|
|
55
|
+
dateTime: (iso: string) => string;
|
|
56
|
+
/** What stands in for a field the row does not carry. */
|
|
57
|
+
placeholder: string;
|
|
58
|
+
}
|