@12-apps/payments-frontend 1.0.0

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.
Files changed (34) hide show
  1. package/eslint.config.js +34 -0
  2. package/package.json +63 -0
  3. package/src/__tests__/checkout-confirmation.test.tsx +177 -0
  4. package/src/__tests__/connection-state.test.tsx +84 -0
  5. package/src/__tests__/context.test.tsx +88 -0
  6. package/src/__tests__/controlled-provider.test.tsx +116 -0
  7. package/src/__tests__/credential-confirm.test.tsx +193 -0
  8. package/src/__tests__/initial-provider.test.tsx +81 -0
  9. package/src/__tests__/provider-priority-list.test.tsx +159 -0
  10. package/src/__tests__/provider-status-bar.test.tsx +152 -0
  11. package/src/__tests__/verification-slot.test.tsx +125 -0
  12. package/src/client.ts +200 -0
  13. package/src/components/CheckoutFlow.tsx +169 -0
  14. package/src/components/CheckoutPayment.tsx +379 -0
  15. package/src/components/ConfirmCredentialSave.tsx +106 -0
  16. package/src/components/CredentialFields.tsx +158 -0
  17. package/src/components/CredentialFormAlerts.tsx +144 -0
  18. package/src/components/EnvironmentTabs.tsx +109 -0
  19. package/src/components/PaymentProviderSettings.tsx +267 -0
  20. package/src/components/ProviderConnection.tsx +251 -0
  21. package/src/components/ProviderCredentialForm.tsx +387 -0
  22. package/src/components/ProviderList.tsx +126 -0
  23. package/src/components/ProviderPanel.tsx +300 -0
  24. package/src/components/ProviderPriorityList.tsx +293 -0
  25. package/src/components/ProviderSetupGuide.tsx +263 -0
  26. package/src/components/ProviderStatusBar.tsx +192 -0
  27. package/src/components/SetupGuideSection.tsx +190 -0
  28. package/src/components/checkout-ack.ts +106 -0
  29. package/src/components/connection-state.ts +66 -0
  30. package/src/components/credential-rules.ts +120 -0
  31. package/src/components/rich-text.tsx +27 -0
  32. package/src/components/settings-state.ts +211 -0
  33. package/src/context.tsx +144 -0
  34. package/src/index.ts +69 -0
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ import { Box, Chip, FormControlLabel, Stack, Switch, Tooltip, Typography } from '@mui/material';
4
+
5
+ import type { MaskedProviderConfig, ProviderDescriptor } from '@12-apps/payments-backend';
6
+
7
+ import { isConnected } from './connection-state';
8
+
9
+ /**
10
+ * The provider's headline: what state it is in, and the switch that decides
11
+ * whether real buyers are sent to it.
12
+ *
13
+ * Its own file since the credential form grew past what one module should
14
+ * carry, and because this bar is rendered above BOTH connection branches —
15
+ * it belongs to the provider, not to the form.
16
+ */
17
+
18
+ /** What the chip says, in the owner's language, and what it is claiming. */
19
+ interface StatusBadge {
20
+ label: string;
21
+ color: 'default' | 'info' | 'success' | 'error';
22
+ }
23
+
24
+ /**
25
+ * Three different facts used to share one word.
26
+ *
27
+ * The chip printed the stored `status` verbatim, so a store that had passed
28
+ * "Testar conexão" read `VERIFIED` — and `VERIFIED` is a claim about
29
+ * CREDENTIALS AUTHENTICATING, nothing more. PagBank answers yes to it while
30
+ * refusing every real charge until homologação; InfinitePay answers yes with
31
+ * Checkout Integrado switched off, which creates no links at all. An owner
32
+ * reading "verified" reasonably concludes the store can take money.
33
+ *
34
+ * So the technical fact and the money fact get different words. `CONEXÃO OK`
35
+ * says the account was reached. `VERIFICADO` is reserved for `chargeVerifiedAt`
36
+ * — a real payment that actually landed — and is the only one painted green.
37
+ */
38
+ export function statusBadge(
39
+ config: MaskedProviderConfig | null,
40
+ descriptor?: ProviderDescriptor,
41
+ ): StatusBadge {
42
+ if (config?.chargeVerifiedAt) return { label: 'VERIFICADO', color: 'success' };
43
+ // A store that has entered nothing has not FAILED at anything. The stored
44
+ // status outlives the credential it described — clearing the field leaves the
45
+ // last verdict behind — so a red FALHOU sat above an empty form, accusing the
46
+ // owner of a mistake they had not yet had the chance to make.
47
+ if (descriptor && !anyCredentialStored(config, descriptor)) {
48
+ return { label: 'NÃO VERIFICADO', color: 'default' };
49
+ }
50
+ if (config?.status === 'RECONNECT_REQUIRED') return { label: 'RECONECTAR', color: 'error' };
51
+ if (isConnected(config)) return { label: 'CONEXÃO OK', color: 'info' };
52
+ // A FAILED probe lands here, and it says NÃO VERIFICADO like every other
53
+ // not-yet-connected state. `FALHOU` was a third word for the same fact — the
54
+ // store cannot take money through this provider yet — and it was the only
55
+ // one painted red, so a mistyped InfiniteTag read as a system fault rather
56
+ // than as a step still to finish. The probe's own sentence is directly
57
+ // underneath and says WHICH tag and WHAT to do about it, which no chip can.
58
+ //
59
+ // `RECONNECT_REQUIRED` stays red on purpose: that one is a connection that
60
+ // WAS working and stopped, which is news rather than a step.
61
+ return { label: 'NÃO VERIFICADO', color: 'default' };
62
+ }
63
+
64
+ /**
65
+ * Does this provider hold any credential at all in the environment in use?
66
+ *
67
+ * Only meaningful for providers that HAVE a credential form: under OAuth the
68
+ * grant is the credential and no field is ever filled, so those keep whatever
69
+ * the probe last said.
70
+ */
71
+ function anyCredentialStored(
72
+ config: MaskedProviderConfig | null,
73
+ descriptor: ProviderDescriptor,
74
+ ): boolean {
75
+ // `?? []` because a descriptor may legitimately declare no schema at all, and
76
+ // a status chip must never be the thing that throws on a settings screen.
77
+ if ((descriptor.credentialSchema ?? []).length === 0) return true;
78
+ if (!config) return false;
79
+ const stored = config.environments[config.environment] ?? {};
80
+ return Object.values(stored).some((field) => field.configured);
81
+ }
82
+
83
+ /**
84
+ * May the switch be touched, and if not, why not?
85
+ *
86
+ * Activation is EARNED BY CHARGING. The status chip cannot be the gate, and
87
+ * that is the whole lesson: `VERIFIED` comes from the credential probe, which
88
+ * only asks whether the keys authenticate. A PagBank Connect grant answers yes
89
+ * to that while refusing every real charge with `403 ACCESS_DENIED` until the
90
+ * integration is homologated — so a store read `PagBank [VERIFIED] [on] Ativo`
91
+ * and declined every shopper.
92
+ *
93
+ * Two qualifications, both learned the hard way:
94
+ *
95
+ * - Only providers that CAN charge are held to having charged. Stripe and
96
+ * InfinitePay have no browser tokenization written, so requiring the proof
97
+ * would not make their "Ativo" honest — it would make them unactivatable.
98
+ * - Turning OFF is never blocked. An owner must be able to pull a provider out
99
+ * of rotation at once, and rows enabled before this rule existed would
100
+ * otherwise be stuck on with no way down.
101
+ *
102
+ * The hint names no amount. It used to promise "a cobrança de teste de R$ 0,01"
103
+ * while the step it points at charges R$ 1,01 for InfinitePay, which refuses a
104
+ * one-cent total outright — the figure is a per-provider fact this component
105
+ * does not have, and quoting it from here could only ever be a guess.
106
+ */
107
+ function toggleGate(
108
+ descriptor: ProviderDescriptor,
109
+ config: MaskedProviderConfig | null,
110
+ enabled: boolean,
111
+ ): { lockedOff: boolean; hint: string } {
112
+ const provable = descriptor.capabilities?.activationCharge === true;
113
+ // `chargeVerifiedAt` PERSISTS once set, so an owner who paused a proven
114
+ // provider can resume it without charging a second time.
115
+ const ready = provable ? Boolean(config?.chargeVerifiedAt) : isConnected(config);
116
+ const lockedOff = !ready && !enabled;
117
+ if (!lockedOff) return { lockedOff, hint: '' };
118
+ return {
119
+ lockedOff,
120
+ hint: provable
121
+ ? 'Este provedor só passa a receber vendas depois dos 3 passos abaixo.'
122
+ : 'Conecte e verifique o provedor antes de ativar as vendas.',
123
+ };
124
+ }
125
+
126
+ /**
127
+ * Status chip + the "recebendo vendas" switch, hoisted OUT of the credential
128
+ * form.
129
+ *
130
+ * These belong to the provider, not to the form: a store that connected by
131
+ * OAuth is told no key needs copying, so it has no reason to open the
132
+ * "Prefiro informar as credenciais manualmente" disclosure — and while this
133
+ * lived inside it, the switch that actually lets checkout charge was hidden in
134
+ * there. The connect card said "conectado" and the page said "nenhum provedor
135
+ * está ativo", with the control to reconcile them out of sight.
136
+ *
137
+ * The switch is labelled with its CONSEQUENCE rather than with "Ativo". "Ativo"
138
+ * is a word about a database row; "Recebendo vendas" is the thing the owner is
139
+ * deciding, and the difference matters most in the state where the control is
140
+ * dead — where "Ativo" plus a greyed-out toggle reads as a broken screen unless
141
+ * you hover it. The reason is printed underneath instead of hidden in a
142
+ * tooltip, since the owner who most needs it is the one who cannot click.
143
+ */
144
+ export function ProviderStatusBar({
145
+ descriptor,
146
+ config,
147
+ busy,
148
+ onToggle,
149
+ }: {
150
+ descriptor: ProviderDescriptor;
151
+ config: MaskedProviderConfig | null;
152
+ busy: boolean;
153
+ onToggle: (enabled: boolean) => void;
154
+ }) {
155
+ const enabled = config?.enabled ?? false;
156
+ const { lockedOff, hint } = toggleGate(descriptor, config, enabled);
157
+ const badge = statusBadge(config, descriptor);
158
+
159
+ return (
160
+ <Stack spacing={0.5}>
161
+ <Stack direction="row" spacing={1} alignItems="center">
162
+ <Typography variant="h6">{descriptor.displayName}</Typography>
163
+ <Chip size="small" data-testid="payments-status" label={badge.label} color={badge.color} />
164
+ {/* The switch belongs at the far edge: it is the one control here that
165
+ changes what buyers experience, and crowding it against the status
166
+ chip made the two read as one compound widget. */}
167
+ <Box sx={{ flexGrow: 1 }} />
168
+ <Tooltip title={hint}>
169
+ {/* A disabled control fires no events, so the tooltip needs a live wrapper. */}
170
+ <span>
171
+ <FormControlLabel
172
+ control={
173
+ <Switch
174
+ data-testid="payments-enabled-toggle"
175
+ checked={enabled}
176
+ disabled={busy || lockedOff}
177
+ onChange={(_, next) => onToggle(next)}
178
+ />
179
+ }
180
+ label={enabled ? 'Recebendo vendas' : 'Não está recebendo'}
181
+ />
182
+ </span>
183
+ </Tooltip>
184
+ </Stack>
185
+ {lockedOff ? (
186
+ <Typography variant="caption" color="text.secondary" data-testid="payments-enable-hint">
187
+ {hint}
188
+ </Typography>
189
+ ) : null}
190
+ </Stack>
191
+ );
192
+ }
@@ -0,0 +1,190 @@
1
+ 'use client';
2
+
3
+ import { Box } from '@mui/material';
4
+ import type { JSX, ReactNode } from 'react';
5
+
6
+ import type { ProviderSetupGuide as Guide, SetupSection } from '@12-apps/payments-backend';
7
+
8
+ import { DoneRow } from './CredentialFields';
9
+ import { ProviderSetupGuide } from './ProviderSetupGuide';
10
+
11
+ /**
12
+ * The walkthrough, plus the one step whose completion only the OWNER can
13
+ * report.
14
+ *
15
+ * ## The action id is a contract, like PagBank's
16
+ *
17
+ * `SetupStep.action` has always been an opaque id an adapter and a host agree
18
+ * on out of band — PagBank's `homologacao-anexo` asks the host to generate the
19
+ * evidence file its review form wants. This is the second such agreement, and
20
+ * the first whose answer is a FACT rather than a side effect: InfinitePay ships
21
+ * Checkout Integrado disabled and publishes nothing that reports its state, so
22
+ * the only reading available is the owner's.
23
+ *
24
+ * A guide that offers this action is asking "have you done this?", and a host
25
+ * that handles it owes an answer it can remember.
26
+ */
27
+ export const CHECKOUT_CONFIRM_ACTION = 'checkout-integrado-confirmado';
28
+
29
+ /** Does this section end in a question only the owner can answer? */
30
+ function isConfirmable(section: SetupSection): boolean {
31
+ return section.steps.some((step) => step.action === CHECKOUT_CONFIRM_ACTION);
32
+ }
33
+
34
+ /**
35
+ * Which stage the walkthrough is REALLY on.
36
+ *
37
+ * The server reports what it can prove — credentials that reach an account, a
38
+ * charge that landed — and that is not the whole answer. One step in the middle
39
+ * can only be confirmed by the owner, because no API reports whether Checkout
40
+ * Integrado is switched on. Left to the server's number alone the screen ticked
41
+ * that step off and jumped to step 3 the moment the probe passed, having never
42
+ * shown it.
43
+ *
44
+ * So an unconfirmed confirmable section holds the walkthrough where it is. And
45
+ * `editing` beats everything: reopening a finished step means going back to it.
46
+ */
47
+ function effectiveStage(
48
+ guide: Guide,
49
+ confirmed: boolean,
50
+ editing: boolean,
51
+ stored = true,
52
+ ): number {
53
+ const stage = guide.activeStage ?? 0;
54
+ if (editing) return 0;
55
+ // Nothing stored for the environment being LOOKED AT ⇒ step 1, whatever the
56
+ // active environment has achieved. `activeStage` is computed from the config
57
+ // the store actually charges with, so on the other tab it reported progress
58
+ // made somewhere else — a walkthrough claiming steps 1 and 2 were done over
59
+ // an empty Produção field, on the screen whose whole subject is which
60
+ // account receives the money.
61
+ if (!stored) return 0;
62
+ if (confirmed) return stage;
63
+ const waiting = confirmableStage(guide);
64
+ return waiting < 0 ? stage : Math.min(stage, waiting);
65
+ }
66
+
67
+ /** Index of the stage whose section ends in a question only the owner can answer. */
68
+ function confirmableStage(guide: Guide): number {
69
+ return guide.stages.findIndex((stage) => {
70
+ const section = guide.sections.find((candidate) => candidate.id === stage.id);
71
+ return section ? isConfirmable(section) : false;
72
+ });
73
+ }
74
+
75
+ /**
76
+ * The section the walkthrough is showing, or null when it has nothing left.
77
+ *
78
+ * Exported because the ACTIVATION STEP needs the same answer: it is the last
79
+ * stage, so it may only appear once the guide is out of sections. Computing it
80
+ * twice is how the screen ended up rendering "Passo 3 · Ative as vendas" —
81
+ * complete with its pay button — directly under "Passo 2".
82
+ */
83
+ export function openSection(
84
+ guide: Guide | null,
85
+ confirmed: boolean,
86
+ editing: boolean,
87
+ stored = true,
88
+ ) {
89
+ if (!guide) return null;
90
+ return sectionAt(guide, effectiveStage(guide, confirmed, editing, stored));
91
+ }
92
+
93
+ /** The section paired with a stage, when the adapter ships one for it. */
94
+ function sectionAt(guide: Guide, stage: number): SetupSection | null {
95
+ const wanted = guide.stages[stage];
96
+ if (!wanted) return null;
97
+ return guide.sections.find((section) => section.id === wanted.id) ?? null;
98
+ }
99
+
100
+ export interface SetupGuideSectionProps {
101
+ guide: Guide | null;
102
+ /** The owner has confirmed the step that only they can see. */
103
+ confirmed: boolean;
104
+ onConfirm: () => void;
105
+ /** Re-open the confirmed step — the owner wants to check it again. */
106
+ onReopen: () => void;
107
+ /** Rows for steps finished elsewhere (a saved credential), shown alongside. */
108
+ rows?: ReactNode;
109
+ /** The live control for the open step — the credential form. */
110
+ sectionFooter?: ReactNode;
111
+ /** The owner has reopened a finished step; show that step, not the current one. */
112
+ editing?: boolean;
113
+ /** The environment on screen holds its credentials — see `renderGuide`. */
114
+ stored?: boolean;
115
+ }
116
+
117
+ /**
118
+ * A confirmed step collapses to one line, exactly as a saved credential does.
119
+ *
120
+ * Not removed: "I told you this was on" is the claim step 3 is about to test,
121
+ * and when the provider then refuses to mint a link, the owner needs the row
122
+ * still there to press Revisar on.
123
+ */
124
+ function ConfirmedRow({ section, onReopen }: { section: SetupSection; onReopen: () => void }) {
125
+ return (
126
+ <DoneRow
127
+ testId="payments-setup-confirmed"
128
+ label={section.doneSummary?.label ?? section.title}
129
+ value={section.doneSummary?.value ?? 'Confirmado por você'}
130
+ editLabel="Revisar"
131
+ onEdit={onReopen}
132
+ />
133
+ );
134
+ }
135
+
136
+ export function SetupGuideSection({
137
+ guide,
138
+ confirmed,
139
+ onConfirm,
140
+ onReopen,
141
+ rows,
142
+ sectionFooter,
143
+ editing = false,
144
+ stored = true,
145
+ }: SetupGuideSectionProps): JSX.Element {
146
+ // No walkthrough — PagBank ships none, because under Connect the platform is
147
+ // reviewed centrally and the owner has nothing to follow. The SLOTS still
148
+ // have to render: they are the credential form, and swallowing them left a
149
+ // provider with a status chip, a pair of environment tabs and no way to type
150
+ // anything in.
151
+ if (!guide) {
152
+ return (
153
+ <>
154
+ {rows}
155
+ {sectionFooter}
156
+ </>
157
+ );
158
+ }
159
+
160
+ const stage = effectiveStage(guide, confirmed, editing, stored);
161
+ const open = sectionAt(guide, stage);
162
+ // The confirmed step keeps a row of its own once the walkthrough has moved
163
+ // past it — it is the claim step 3 is about to test, and when the provider
164
+ // refuses to mint a link the owner needs somewhere to press Revisar.
165
+ const settledStage = confirmableStage(guide);
166
+ const settled = confirmed && !editing && settledStage >= 0 && stage > settledStage;
167
+ const settledSection = settled ? sectionAt(guide, settledStage) : null;
168
+
169
+ return (
170
+ <Box data-testid="payments-setup">
171
+ <ProviderSetupGuide
172
+ guide={{ ...guide, sections: open ? [open] : [] }}
173
+ activeStage={stage}
174
+ actions={{
175
+ [CHECKOUT_CONFIRM_ACTION]: {
176
+ label: 'Já habilitei o Checkout Integrado',
177
+ run: onConfirm,
178
+ },
179
+ }}
180
+ beforeSections={
181
+ <>
182
+ {rows}
183
+ {settledSection ? <ConfirmedRow section={settledSection} onReopen={onReopen} /> : null}
184
+ </>
185
+ }
186
+ sectionFooter={sectionFooter}
187
+ />
188
+ </Box>
189
+ );
190
+ }
@@ -0,0 +1,106 @@
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+
3
+ /**
4
+ * "I have switched Checkout Integrado on" — the one setup fact no API answers.
5
+ *
6
+ * InfinitePay ships Checkout Integrado disabled and exposes nothing that
7
+ * reports its state: `payment_check` resolves the handle and stops there. So a
8
+ * store can pass "Testar conexão", read a green result, and still be unable to
9
+ * create a single payment link. The only way to know is to ask the owner, and
10
+ * the only honest thing to do with the answer is treat it as a claim — which
11
+ * step 3 then either confirms, by minting a link, or refutes, by being refused.
12
+ *
13
+ * ## Why the browser keeps it
14
+ *
15
+ * It is not a fact about the merchant's account, it is a fact about what the
16
+ * owner has told us, so putting it in `payment_provider_configs` would dress a
17
+ * self-report up as provider state — and the schema already carries one field
18
+ * (`chargeVerifiedAt`) that means "proved by money moving". Two fields one
19
+ * letter apart in meaning is how a screen ends up claiming a store can charge.
20
+ *
21
+ * `localStorage` and not React state because the flow deliberately LEAVES the
22
+ * page: the owner pays on InfinitePay's site and comes back. In-memory, that
23
+ * round trip would drop the acknowledgement and bounce them to step 2 —
24
+ * hiding the panel that is polling the payment they just made.
25
+ *
26
+ * Keyed by the client's base URL, which is tenant-scoped
27
+ * (`/api/admin/<slug>/payments`), so an owner who administers two stores does
28
+ * not confirm the setting once and have it apply to the other.
29
+ */
30
+
31
+ function storageKey(scope: string, provider: string): string {
32
+ return `payments:checkout-confirmed:${scope}:${provider}`;
33
+ }
34
+
35
+ /** Cross-component notification — two panels must not disagree about this. */
36
+ const listeners = new Set<() => void>();
37
+
38
+ function subscribe(listener: () => void): () => void {
39
+ listeners.add(listener);
40
+ // A confirmation made in another TAB is the same owner saying the same
41
+ // thing; nothing here should contradict it.
42
+ window.addEventListener('storage', listener);
43
+ return () => {
44
+ listeners.delete(listener);
45
+ window.removeEventListener('storage', listener);
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Every read and write is guarded: `localStorage` throws outright in a
51
+ * partitioned or storage-blocked context, and a setup screen must not go blank
52
+ * because a browser declined to remember a checkbox. Unavailable storage
53
+ * simply means "not confirmed yet" — the owner confirms again, which costs a
54
+ * click and nothing else.
55
+ */
56
+ function read(key: string): boolean {
57
+ try {
58
+ return window.localStorage.getItem(key) === 'true';
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ function write(key: string, value: boolean): void {
65
+ try {
66
+ if (value) window.localStorage.setItem(key, 'true');
67
+ else window.localStorage.removeItem(key);
68
+ } catch {
69
+ // Nothing to recover: the caller's UI already reflects the intent, and the
70
+ // worst case is being asked to confirm once more on the next visit.
71
+ }
72
+ listeners.forEach((listener) => listener());
73
+ }
74
+
75
+ interface CheckoutAck {
76
+ /** The owner has said Checkout Integrado is on for this store. */
77
+ confirmed: boolean;
78
+ confirm: () => void;
79
+ /**
80
+ * Withdraw it. Called when the provider REFUSES to mint a link, which is the
81
+ * strongest available evidence that the setting is in fact off — leaving the
82
+ * claim standing there would keep the owner on a step 3 that cannot work.
83
+ */
84
+ withdraw: () => void;
85
+ }
86
+
87
+ export function useCheckoutAck(scope: string, provider: string | null): CheckoutAck {
88
+ const key = provider ? storageKey(scope, provider) : null;
89
+
90
+ const confirmed = useSyncExternalStore(
91
+ subscribe,
92
+ () => (key ? read(key) : false),
93
+ // Server render: nothing is confirmed, which is the state that shows the
94
+ // step rather than the state that skips it.
95
+ () => false,
96
+ );
97
+
98
+ const confirm = useCallback(() => {
99
+ if (key) write(key, true);
100
+ }, [key]);
101
+ const withdraw = useCallback(() => {
102
+ if (key) write(key, false);
103
+ }, [key]);
104
+
105
+ return { confirmed, confirm, withdraw };
106
+ }
@@ -0,0 +1,66 @@
1
+ import type { MaskedProviderConfig } from '@12-apps/payments-backend';
2
+
3
+ /**
4
+ * Is there actually a connection here?
5
+ *
6
+ * The one definition, because the screen shipped with two. Disconnecting does
7
+ * NOT delete the row — it empties every environment's credentials and resets
8
+ * the status — so `config !== null` keeps answering "yes" for a store that has
9
+ * nothing left to charge with. The provider list said `Conectado` while the
10
+ * panel one click away offered `Conectar com PagBank`.
11
+ *
12
+ * `RECONNECT_REQUIRED` counts as connected on purpose: that store demonstrably
13
+ * worked and still holds credentials, it is the AUTHORIZATION that lapsed.
14
+ */
15
+ export function isConnected(config: MaskedProviderConfig | null | undefined): boolean {
16
+ const status = config?.status;
17
+ return status === 'VERIFIED' || status === 'RECONNECT_REQUIRED';
18
+ }
19
+
20
+ /**
21
+ * Is there enough here to attempt the activation charge?
22
+ *
23
+ * Deliberately NOT {@link isConnected}. That reads `status`, which only the
24
+ * credential PROBE sets — so a store that had pasted perfectly good keys saw no
25
+ * activation step at all until it ran "Testar conexão" first, and for a
26
+ * key-based provider that probe answers a question nobody asked. The charge is
27
+ * the real test; making it wait behind a weaker one is backwards.
28
+ *
29
+ * Credentials in the ACTIVE environment are the bar, plus `isConnected` for the
30
+ * OAuth case, where the grant is the credential and no field is ever filled in.
31
+ */
32
+ export function canAttemptCharge(config: MaskedProviderConfig | null | undefined): boolean {
33
+ if (!config) return false;
34
+ if (isConnected(config)) return true;
35
+ // Credentials alone are NOT enough for a provider whose activation charge is
36
+ // a real payment. Offering "Pagar R$1,01 e ativar" beside an UNVERIFIED tag
37
+ // invites an owner to send real money through a handle nothing has checked —
38
+ // and a mistyped InfiniteTag pays a stranger, irreversibly. It also put the
39
+ // step-3 card on screen while the walkthrough was still saying "step 2: go
40
+ // enable Checkout", which is two answers to "what do I do now".
41
+ //
42
+ // The probe is one click and costs nothing, so requiring it first takes away
43
+ // no capability — it just orders the two steps the way the guide already
44
+ // describes them.
45
+ if (config.chargeVerifiedAt) return true;
46
+ return false;
47
+ }
48
+
49
+ /** What a provider card reports at a glance. */
50
+ type ConnectionBadge =
51
+ | { label: 'Ativo'; color: 'success' }
52
+ | { label: 'Conectado'; color: 'default' }
53
+ | { label: 'Reconectar'; color: 'error' }
54
+ | { label: 'Não conectado'; color: 'warning' };
55
+
56
+ /**
57
+ * `enabled` is deliberately checked first: it is the only state that means
58
+ * money can move, and a store reading `Ativo` has already passed everything
59
+ * below it.
60
+ */
61
+ export function connectionBadge(config: MaskedProviderConfig | null): ConnectionBadge {
62
+ if (config?.enabled) return { label: 'Ativo', color: 'success' };
63
+ if (config?.status === 'RECONNECT_REQUIRED') return { label: 'Reconectar', color: 'error' };
64
+ if (isConnected(config)) return { label: 'Conectado', color: 'default' };
65
+ return { label: 'Não conectado', color: 'warning' };
66
+ }
@@ -0,0 +1,120 @@
1
+ import type {
2
+ CredentialFieldSpec,
3
+ MaskedProviderConfig,
4
+ PaymentEnvironment,
5
+ ProviderDescriptor,
6
+ } from '@12-apps/payments-backend';
7
+
8
+ import type { PendingSave } from './ConfirmCredentialSave';
9
+
10
+ /**
11
+ * The DECISIONS the credential form makes, with no JSX anywhere near them.
12
+ *
13
+ * Each one is a rule about money rather than about layout — is there anything
14
+ * stored to probe, could this value possibly be a real handle, is it changing
15
+ * at all, what does the button commit — and every one of them was learned from
16
+ * a screen that got it wrong. Kept together, and apart from the rendering, so
17
+ * they can be read as the set of rules they are.
18
+ */
19
+
20
+ /**
21
+ * Is there actually something stored to probe?
22
+ *
23
+ * Read from the SERVER's answer to the save rather than from the form: blank
24
+ * fields are preserved rather than cleared, so what the browser just typed does
25
+ * not describe what is now on record.
26
+ */
27
+ export function allRequiredStored(
28
+ descriptor: ProviderDescriptor,
29
+ config: MaskedProviderConfig,
30
+ environment: PaymentEnvironment,
31
+ ): boolean {
32
+ const stored = config.environments[environment] ?? {};
33
+ return descriptor.credentialSchema
34
+ .filter((spec) => spec.required)
35
+ .every((spec) => stored[spec.key]?.configured === true);
36
+ }
37
+
38
+ /**
39
+ * Does every edited field match the SHAPE its provider declares?
40
+ *
41
+ * Shape only — it cannot know whether an account exists, which is what the
42
+ * probe immediately after the save is for. But `$` followed by a few safe
43
+ * characters is free to check, and it stops the ordinary slips (an e-mail, an
44
+ * `@handle` with no `$`) reaching a confirmation dialog that would then ask the
45
+ * owner to vouch for them.
46
+ */
47
+ export function fieldsWellFormed(
48
+ descriptor: ProviderDescriptor,
49
+ values: Record<string, string>,
50
+ ): boolean {
51
+ return descriptor.credentialSchema.every((spec) => {
52
+ const value = values[spec.key];
53
+ if (!spec.pattern || value === undefined || value === '') return true;
54
+ return new RegExp(spec.pattern).test(value.trim());
55
+ });
56
+ }
57
+
58
+ export function saveLabel(descriptor: ProviderDescriptor): string {
59
+ const required = descriptor.credentialSchema.filter((field) => field.required);
60
+ const only = required.length === 1 ? required[0] : undefined;
61
+ if (!only) return 'Salvar';
62
+ return `Salvar ${only.label.replace(/\s*\([^)]*\)\s*$/, '')}`;
63
+ }
64
+
65
+ /**
66
+ * The stored value of the field a finished step collapses around.
67
+ *
68
+ * Non-secret fields keep their value in `hint` (secrets never leave the
69
+ * server), so a provider whose one credential is a public handle can show it
70
+ * back verbatim — which is the entire content of the summary row.
71
+ *
72
+ * Deliberately narrow: EXACTLY ONE required field, and it must be readable
73
+ * back. A form of four fields does not have a one-line summary, and collapsing
74
+ * it would hide inputs the owner still has to reach — so those providers keep
75
+ * the form they have always had.
76
+ */
77
+ export function summaryOf(
78
+ descriptor: ProviderDescriptor,
79
+ config: MaskedProviderConfig | null,
80
+ environment: PaymentEnvironment,
81
+ ): { spec: CredentialFieldSpec; value: string } | null {
82
+ const required = descriptor.credentialSchema.filter((field) => field.required);
83
+ const spec = required.length === 1 ? required[0] : undefined;
84
+ if (!spec || spec.secret) return null;
85
+ const state = config?.environments[environment]?.[spec.key];
86
+ if (!state?.configured || !state.hint) return null;
87
+ return { spec, value: state.hint };
88
+ }
89
+
90
+ /**
91
+ * Which field, if any, needs reading back before the write.
92
+ *
93
+ * Only when it is actually CHANGING: `confirmOnSave` guards against a wrong
94
+ * value being stored, and re-saving the value already stored cannot introduce
95
+ * one. Confirming a no-op would train the owner to click through the dialog,
96
+ * which is the one way to make it useless.
97
+ */
98
+ export function needsConfirmation(
99
+ descriptor: ProviderDescriptor,
100
+ config: MaskedProviderConfig | null,
101
+ environment: PaymentEnvironment,
102
+ values: Record<string, string>,
103
+ ): PendingSave | null {
104
+ for (const spec of descriptor.credentialSchema) {
105
+ if (!spec.confirmOnSave) continue;
106
+ const next = values[spec.key];
107
+ if (next === undefined || next === '') continue;
108
+ if (next === config?.environments[environment]?.[spec.key]?.hint) continue;
109
+ return { spec, value: next };
110
+ }
111
+ return null;
112
+ }
113
+
114
+ /**
115
+ * Everything the form remembers, and the two writes it can make.
116
+ *
117
+ * Hoisted out of the component so that stays about LAYOUT — which of the two
118
+ * shapes (fields or summary row) is on screen — while this owns the rules that
119
+ * decide it. Splitting the two is also what keeps each inside the size gate.
120
+ */