@12-apps/payments-frontend 1.16.0 → 1.18.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.
- package/package.json +4 -4
- package/src/components/ProviderConnection.tsx +54 -0
- package/src/components/checkout/apple-pay-button.tsx +189 -0
- package/src/components/checkout/checkout-flow.tsx +9 -1
- package/src/components/checkout/checkout-steps.tsx +7 -0
- package/src/components/checkout/client-context.tsx +2 -0
- package/src/components/checkout/client.ts +10 -0
- package/src/components/checkout/google-pay-button.tsx +263 -0
- package/src/components/checkout/method-capability.ts +45 -0
- package/src/components/checkout/providers/pix-and-card.tsx +8 -23
- package/src/components/checkout/providers/types.ts +8 -0
- package/src/components/checkout/transport.ts +38 -17
- package/src/components/checkout/types.ts +41 -0
- package/src/components/checkout/use-wallet-charge.ts +129 -0
- package/src/components/checkout/wallet-pane.tsx +187 -0
- package/src/components/platform/ConnectApplicationPanel.tsx +77 -0
- package/src/components/platform/ConnectEnvironmentCard.tsx +175 -0
- package/src/components/platform/HomologacaoGuideCard.tsx +86 -0
- package/src/components/platform/HomologacaoOutcomeCard.tsx +170 -0
- package/src/components/platform/PlatformHomologacao.tsx +107 -0
- package/src/flows/create-payment-flows.tsx +1 -0
- package/src/flows/types.ts +7 -0
- package/src/index.ts +47 -0
|
@@ -140,6 +140,51 @@ function toCardLink(link: CheckoutChainLink): CardChainLink {
|
|
|
140
140
|
};
|
|
141
141
|
}
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* Everything the Google Pay button needs to mint a token (FUT-471), or `null`
|
|
145
|
+
* when the button must not render.
|
|
146
|
+
*
|
|
147
|
+
* Read off the chain HEAD only, like the checkout screen: the wallet token is
|
|
148
|
+
* minted against ONE gateway's `gatewayMerchantId`, so only the provider the
|
|
149
|
+
* walk tries first can charge it — a tail entry's declaration cannot be
|
|
150
|
+
* honoured from this browser.
|
|
151
|
+
*
|
|
152
|
+
* FAILS CLOSED, deliberately the opposite of `offeredMethods`' fail-open: a
|
|
153
|
+
* missing config only costs the buyer a button they still have the card form
|
|
154
|
+
* without, while rendering one on a store that cannot charge wallets sends a
|
|
155
|
+
* buyer through the wallet sheet into a guaranteed refusal. Every clause
|
|
156
|
+
* narrows toward NOT rendering: no config, no chain, a head that cannot CARD,
|
|
157
|
+
* no `GOOGLE_PAY` declaration, no gateway parameters, no merchant id.
|
|
158
|
+
*/
|
|
159
|
+
export function googlePayConfig(
|
|
160
|
+
config: CheckoutProviderConfig | null,
|
|
161
|
+
): { gateway: string; gatewayMerchantId: string } | null {
|
|
162
|
+
const head = config?.chain?.[0];
|
|
163
|
+
if (!head?.methods.includes("CARD")) return null;
|
|
164
|
+
if (!head.wallets?.includes("GOOGLE_PAY")) return null;
|
|
165
|
+
const params = head.googlePay;
|
|
166
|
+
if (!params?.gatewayMerchantId) return null;
|
|
167
|
+
return { gateway: params.gateway, gatewayMerchantId: params.gatewayMerchantId };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Whether the chain head declares Apple Pay (FUT-472) — the capability half
|
|
172
|
+
* of the button's gate; the DEVICE half (`ApplePaySession` exists and can
|
|
173
|
+
* pay) is `applePaySupported()` in `apple-pay-button.tsx`, because it is a
|
|
174
|
+
* browser fact, not a store fact.
|
|
175
|
+
*
|
|
176
|
+
* FAILS CLOSED like {@link googlePayConfig}, and read off the HEAD for the
|
|
177
|
+
* same reason: the Apple token is decrypted with the head merchant's
|
|
178
|
+
* certificate, so only the provider the walk tries first can charge it.
|
|
179
|
+
* Unlike Google there are no client parameters to publish — merchant
|
|
180
|
+
* validation runs server-side through the host's port.
|
|
181
|
+
*/
|
|
182
|
+
export function applePayDeclared(config: CheckoutProviderConfig | null): boolean {
|
|
183
|
+
const head = config?.chain?.[0];
|
|
184
|
+
if (!head?.methods.includes("CARD")) return false;
|
|
185
|
+
return head.wallets?.includes("APPLE_PAY") ?? false;
|
|
186
|
+
}
|
|
187
|
+
|
|
143
188
|
/**
|
|
144
189
|
* The methods the picker may offer, from the chain's declared capabilities
|
|
145
190
|
* (FUT-698). `null` config — still loading, or a fetch blip — fails OPEN like
|
|
@@ -12,37 +12,22 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { JSX } from "react";
|
|
14
14
|
|
|
15
|
-
import { CardView } from "../card-view";
|
|
16
|
-
import { cardChain, cardTokenization } from "../method-capability";
|
|
17
15
|
import { PixView } from "../pix-view";
|
|
16
|
+
import { WalletCardPane } from "../wallet-pane";
|
|
18
17
|
|
|
19
18
|
import type { ProviderCheckoutScreenProps } from "./types";
|
|
20
19
|
|
|
21
|
-
export function PixAndCardScreen({
|
|
22
|
-
order,
|
|
23
|
-
buyer,
|
|
24
|
-
config,
|
|
25
|
-
tenantSlug,
|
|
26
|
-
onResolved,
|
|
27
|
-
pollIntervalMs,
|
|
28
|
-
}: ProviderCheckoutScreenProps): JSX.Element | null {
|
|
20
|
+
export function PixAndCardScreen(props: ProviderCheckoutScreenProps): JSX.Element | null {
|
|
21
|
+
const { order, onResolved, pollIntervalMs } = props;
|
|
29
22
|
if (order?.method === "PIX") {
|
|
30
23
|
return <PixView order={order} onResolved={onResolved} pollIntervalMs={pollIntervalMs} />;
|
|
31
24
|
}
|
|
32
25
|
if (order?.method === "CARD") {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
// The whole chain (FUT-563): one instrument is minted per provider so
|
|
39
|
-
// the charge survives the first one failing, with nothing re-typed.
|
|
40
|
-
providerChain={cardChain(config)}
|
|
41
|
-
tenantSlug={tenantSlug}
|
|
42
|
-
onResolved={onResolved}
|
|
43
|
-
pollIntervalMs={pollIntervalMs}
|
|
44
|
-
/>
|
|
45
|
-
);
|
|
26
|
+
// The card pane, with its wallet fast lane above the form (FUT-471/472).
|
|
27
|
+
// The pane reads the chain itself (FUT-563: one instrument per provider,
|
|
28
|
+
// so the charge survives the first one failing with nothing re-typed) and
|
|
29
|
+
// renders exactly the old CardView for a store with no wallet.
|
|
30
|
+
return <WalletCardPane {...props} order={order} />;
|
|
46
31
|
}
|
|
47
32
|
// No order yet — the shell is still showing the picker, and raises one as
|
|
48
33
|
// soon as a method is chosen.
|
|
@@ -52,6 +52,14 @@ export interface ProviderCheckoutScreenProps {
|
|
|
52
52
|
tenantSlug?: string;
|
|
53
53
|
/** The shell's polling cadence, passed through so tests can shorten it. */
|
|
54
54
|
pollIntervalMs?: number;
|
|
55
|
+
/**
|
|
56
|
+
* The host's Apple Pay merchant-validation port (FUT-472): exchange the
|
|
57
|
+
* session's `validationURL` for an Apple merchant session, SERVER-SIDE —
|
|
58
|
+
* the merchant identity certificate must never reach a browser. Optional;
|
|
59
|
+
* absent, the Apple Pay sheet cannot start and the pane says so while the
|
|
60
|
+
* card form stays the way to pay.
|
|
61
|
+
*/
|
|
62
|
+
validateApplePayMerchant?: (validationURL: string) => Promise<unknown>;
|
|
55
63
|
/** A terminal status — the shell moves to Confirmação. */
|
|
56
64
|
onResolved: (status: OrderStatus) => void;
|
|
57
65
|
}
|
|
@@ -21,6 +21,7 @@ import { err, ok, type Result } from "../../result";
|
|
|
21
21
|
import type {
|
|
22
22
|
ChargeCardInput,
|
|
23
23
|
ChargeOutcome,
|
|
24
|
+
ChargeWalletInput,
|
|
24
25
|
CheckoutProviderConfig,
|
|
25
26
|
OrderStatus,
|
|
26
27
|
} from "./types";
|
|
@@ -45,11 +46,13 @@ export interface CheckoutTransport {
|
|
|
45
46
|
headers?: () => HeadersInit | Promise<HeadersInit>;
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
/** The
|
|
49
|
+
/** The six calls the buyer checkout makes, pre-bound to a {@link CheckoutTransport}. */
|
|
49
50
|
export interface CheckoutClient {
|
|
50
51
|
getConfig(tenantSlug: string): Promise<Result<CheckoutProviderConfig>>;
|
|
51
52
|
getStatus(ref: string): Promise<Result<OrderStatus>>;
|
|
52
53
|
charge(input: ChargeCardInput): Promise<Result<ChargeOutcome>>;
|
|
54
|
+
/** A wallet instrument against the same `/charge` route (FUT-471/472). */
|
|
55
|
+
chargeWallet(input: ChargeWalletInput): Promise<Result<ChargeOutcome>>;
|
|
53
56
|
listInstruments(tenantSlug?: string): Promise<SavedCard[]>;
|
|
54
57
|
refreshBrowserKey(input: { orderId: string }): Promise<Result<{ publicKey: string | null }>>;
|
|
55
58
|
}
|
|
@@ -112,6 +115,36 @@ function ambientFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Res
|
|
|
112
115
|
return globalThis.fetch(input, init);
|
|
113
116
|
}
|
|
114
117
|
|
|
118
|
+
/**
|
|
119
|
+
* The FLAT card-charge body the shipped client has always sent. Pinned from
|
|
120
|
+
* both ends by `charge-wire.contract.test.ts`; nothing here may re-nest it.
|
|
121
|
+
*/
|
|
122
|
+
function flatChargeBody(input: ChargeCardInput): string {
|
|
123
|
+
return JSON.stringify({
|
|
124
|
+
orderId: input.orderId,
|
|
125
|
+
token: input.token,
|
|
126
|
+
// One instrument per provider (FUT-563) — the server hands each provider
|
|
127
|
+
// in the chain its own, which is what lets a card charge fail over.
|
|
128
|
+
...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
|
|
129
|
+
saveCard: input.saveCard,
|
|
130
|
+
cardMeta: input.cardMeta,
|
|
131
|
+
taxId: input.taxId,
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The same flat wire with `wallet` in place of `token` (FUT-471): the mount's
|
|
137
|
+
* draft reader takes either, and a body naming both would carry two
|
|
138
|
+
* instruments for one charge. Pinned by `wallet-wire.contract.test.ts`.
|
|
139
|
+
*/
|
|
140
|
+
function flatWalletBody(input: ChargeWalletInput): string {
|
|
141
|
+
return JSON.stringify({
|
|
142
|
+
orderId: input.orderId,
|
|
143
|
+
wallet: input.wallet,
|
|
144
|
+
taxId: input.taxId,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
115
148
|
/**
|
|
116
149
|
* The five checkout calls, bound to one transport.
|
|
117
150
|
*
|
|
@@ -156,22 +189,10 @@ export function createCheckoutClient(transport: CheckoutTransport = {}): Checkou
|
|
|
156
189
|
),
|
|
157
190
|
|
|
158
191
|
charge: (input) =>
|
|
159
|
-
call<ChargeOutcome>("/charge", {
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
body: JSON.stringify({
|
|
164
|
-
orderId: input.orderId,
|
|
165
|
-
token: input.token,
|
|
166
|
-
// One instrument per provider (FUT-563) — the server hands each
|
|
167
|
-
// provider in the chain its own, which is what lets a card charge
|
|
168
|
-
// fail over.
|
|
169
|
-
...(input.tokensByProvider ? { tokensByProvider: input.tokensByProvider } : {}),
|
|
170
|
-
saveCard: input.saveCard,
|
|
171
|
-
cardMeta: input.cardMeta,
|
|
172
|
-
taxId: input.taxId,
|
|
173
|
-
}),
|
|
174
|
-
}),
|
|
192
|
+
call<ChargeOutcome>("/charge", { method: "POST", body: flatChargeBody(input) }),
|
|
193
|
+
|
|
194
|
+
chargeWallet: (input) =>
|
|
195
|
+
call<ChargeOutcome>("/charge", { method: "POST", body: flatWalletBody(input) }),
|
|
175
196
|
|
|
176
197
|
listInstruments: async (tenantSlug) => {
|
|
177
198
|
// Scoped to the store when known (FUT-697): only cards the store's ACTIVE
|
|
@@ -12,6 +12,14 @@
|
|
|
12
12
|
/** Payment methods offered at checkout. Mirrors `Payment.method` (FUT-42). */
|
|
13
13
|
export type PaymentMethod = "PIX" | "CARD";
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The digital wallets a chain entry can charge (FUT-471/472). Mirrors the
|
|
17
|
+
* backend's `WalletType` — a wallet is not a fourth method: it is another way
|
|
18
|
+
* of producing the CARD instrument, so it rides the CARD tile and the CARD
|
|
19
|
+
* charge.
|
|
20
|
+
*/
|
|
21
|
+
export type CheckoutWalletType = "GOOGLE_PAY" | "APPLE_PAY";
|
|
22
|
+
|
|
15
23
|
/**
|
|
16
24
|
* Order lifecycle, aligned with the FUT-43 backend:
|
|
17
25
|
* - `AWAITING_PAYMENT` — order created, charge raised, not yet reconciled (the
|
|
@@ -198,6 +206,20 @@ export interface ChargeCardInput {
|
|
|
198
206
|
taxId?: string;
|
|
199
207
|
}
|
|
200
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Input to charge a WALLET instrument against an order (FUT-471/472) —
|
|
211
|
+
* `ChargeCardInput`'s wallet sibling, posted to the same `/charge` route. One
|
|
212
|
+
* shape for both wallets because the wire is one shape: `key` is whatever the
|
|
213
|
+
* wallet handed the browser (Google's `tokenizationData.token`, Apple's
|
|
214
|
+
* serialized `token.paymentData`), forwarded verbatim and never persisted.
|
|
215
|
+
*/
|
|
216
|
+
export interface ChargeWalletInput {
|
|
217
|
+
orderId: string;
|
|
218
|
+
wallet: { type: CheckoutWalletType; key: string };
|
|
219
|
+
/** Buyer CPF for the provider charge (`customer.tax_id`); never persisted. */
|
|
220
|
+
taxId?: string;
|
|
221
|
+
}
|
|
222
|
+
|
|
201
223
|
/** A buyer field a provider asks for, as `/config` publishes it (FUT-595). */
|
|
202
224
|
export interface CheckoutCustomerField {
|
|
203
225
|
key: "name" | "email" | "taxId" | "phone";
|
|
@@ -236,6 +258,25 @@ export interface CheckoutChainLink {
|
|
|
236
258
|
* never shown a field for.
|
|
237
259
|
*/
|
|
238
260
|
customerSchema?: CheckoutCustomerField[];
|
|
261
|
+
/**
|
|
262
|
+
* The digital wallets THIS entry's card charge accepts (FUT-471/472).
|
|
263
|
+
*
|
|
264
|
+
* Optional, and the DEGRADE DIRECTION IS CLOSED — the opposite of
|
|
265
|
+
* `customerSchema`'s above, because the stakes invert: a chain that asks for
|
|
266
|
+
* nothing produces a refused charge, but a wallet button on a store whose
|
|
267
|
+
* server cannot charge wallets produces a buyer who authorized a payment on
|
|
268
|
+
* the wallet sheet and was then refused. Absent (an older host) means NO
|
|
269
|
+
* wallet buttons, which is exactly what that host's charge route supports.
|
|
270
|
+
*/
|
|
271
|
+
wallets?: CheckoutWalletType[];
|
|
272
|
+
/**
|
|
273
|
+
* Google Pay's `PAYMENT_GATEWAY` tokenizationSpecification parameters for
|
|
274
|
+
* THIS entry (FUT-471), or absent/`null` when it cannot run Google Pay.
|
|
275
|
+
* `gatewayMerchantId: null` means the connection carries no merchant id yet
|
|
276
|
+
* — the button must not render, because a token minted against a missing id
|
|
277
|
+
* charges nobody.
|
|
278
|
+
*/
|
|
279
|
+
googlePay?: { gateway: string; gatewayMerchantId: string | null } | null;
|
|
239
280
|
/**
|
|
240
281
|
* The buyer screen THIS provider's flow needs (FUT-596) — an opaque id the
|
|
241
282
|
* adapter declares and `providers/registry.ts` resolves to a component.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import { useCheckoutClientApi } from "./client-context";
|
|
4
|
+
import { UNRESOLVED_CODE } from "./failure-codes";
|
|
5
|
+
import { rememberHostedOrder } from "./hosted-return";
|
|
6
|
+
import { useCheckoutNavigate } from "./navigate-context";
|
|
7
|
+
import type { BuyerInfo, CheckoutOrder, CheckoutWalletType, OrderStatus } from "./types";
|
|
8
|
+
import { usePaymentPolling } from "./use-payment-polling";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The submit half of a WALLET payment (FUT-471/472) — `useCardSubmit`'s
|
|
12
|
+
* sibling, minus everything a wallet does not have: no form to validate, no
|
|
13
|
+
* instrument to mint per provider (the wallet key is chain-head-bound, see the
|
|
14
|
+
* backend's `core/card-instrument.ts`), no vault opt-in. What remains is the
|
|
15
|
+
* same money path: charge → classify the outcome → poll for the async
|
|
16
|
+
* confirmation → bubble the terminal status up.
|
|
17
|
+
*
|
|
18
|
+
* One hook for BOTH wallets, because the wire is one shape — the buttons only
|
|
19
|
+
* differ in how they acquire the key.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Where the wallet payment stands. `idle` renders the buttons and the card
|
|
24
|
+
* form; anything else replaces them — a live "pay" control under a charge that
|
|
25
|
+
* may already be holding the buyer's money is the double-payment invitation
|
|
26
|
+
* the card view already refuses to render.
|
|
27
|
+
*/
|
|
28
|
+
export type WalletPhase = "idle" | "charging" | "polling";
|
|
29
|
+
|
|
30
|
+
/** Everything a wallet pane renders — state plus the one submit entry point. */
|
|
31
|
+
export interface WalletCharge {
|
|
32
|
+
phase: WalletPhase;
|
|
33
|
+
/** The refusal to show above the form, when the charge came back refused. */
|
|
34
|
+
error: string | null;
|
|
35
|
+
/** The refusal's machine code — an UNRESOLVED charge is not a decline. */
|
|
36
|
+
errorCode: string | null;
|
|
37
|
+
/** The charge is unresolved: no pay control may render (FUT-563). */
|
|
38
|
+
unresolved: boolean;
|
|
39
|
+
pollError: string | null;
|
|
40
|
+
/** The healthy-poll cap elapsed while still AWAITING (FUT-191). */
|
|
41
|
+
pollTimedOut: boolean;
|
|
42
|
+
/**
|
|
43
|
+
* Charge the wallet's key. The button calls this once the sheet resolves.
|
|
44
|
+
* Resolves `true` when the charge was ACCEPTED — paid, confirming, or
|
|
45
|
+
* handed to the provider's page — and `false` on a refusal or decline, so a
|
|
46
|
+
* sheet that must be completed with a status (Apple's) can be honest.
|
|
47
|
+
*/
|
|
48
|
+
payWithKey(type: CheckoutWalletType, key: string): Promise<boolean>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Healthy-poll cap for the wallet AWAITING wait — the card path's own cap
|
|
53
|
+
* (FUT-191): 36 polls ≈ 90 s at the 2500 ms default interval.
|
|
54
|
+
*/
|
|
55
|
+
const WALLET_AWAITING_POLL_CAP = 36;
|
|
56
|
+
|
|
57
|
+
/** The wallet charge state machine. See the module comment. */
|
|
58
|
+
export function useWalletCharge(
|
|
59
|
+
order: CheckoutOrder,
|
|
60
|
+
buyer: BuyerInfo,
|
|
61
|
+
onResolved: (status: OrderStatus) => void,
|
|
62
|
+
pollIntervalMs = 2500,
|
|
63
|
+
): WalletCharge {
|
|
64
|
+
const [phase, setPhase] = useState<WalletPhase>("idle");
|
|
65
|
+
const [error, setError] = useState<string | null>(null);
|
|
66
|
+
const [errorCode, setErrorCode] = useState<string | null>(null);
|
|
67
|
+
const client = useCheckoutClientApi();
|
|
68
|
+
const navigate = useCheckoutNavigate();
|
|
69
|
+
|
|
70
|
+
const { status, error: pollError, timedOut: pollTimedOut } = usePaymentPolling(order.orderId, {
|
|
71
|
+
enabled: phase === "polling",
|
|
72
|
+
intervalMs: pollIntervalMs,
|
|
73
|
+
maxHealthyPolls: WALLET_AWAITING_POLL_CAP,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (status && status !== "AWAITING_PAYMENT") onResolved(status);
|
|
78
|
+
}, [status, onResolved]);
|
|
79
|
+
|
|
80
|
+
const payWithKey = async (type: CheckoutWalletType, key: string): Promise<boolean> => {
|
|
81
|
+
setError(null);
|
|
82
|
+
setErrorCode(null);
|
|
83
|
+
setPhase("charging");
|
|
84
|
+
|
|
85
|
+
const charged = await client.chargeWallet({
|
|
86
|
+
orderId: order.orderId,
|
|
87
|
+
wallet: { type, key },
|
|
88
|
+
// The CPF the Dados step collected — the provider's required-field gate
|
|
89
|
+
// reads it from the charge, the payable row has nowhere to keep it.
|
|
90
|
+
taxId: buyer.taxId,
|
|
91
|
+
});
|
|
92
|
+
if (!charged.ok) {
|
|
93
|
+
setError(charged.error);
|
|
94
|
+
setErrorCode(charged.code ?? null);
|
|
95
|
+
// Back to idle — but an UNRESOLVED code sets `unresolved`, and the pane
|
|
96
|
+
// reads that as "render NO pay control" (FUT-563): some provider may be
|
|
97
|
+
// holding the money, and a live button under "não pague de novo" is what
|
|
98
|
+
// the buyer's thumb reaches for.
|
|
99
|
+
setPhase("idle");
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
// A provider that demands its own page to finish (redirect 3-D Secure,
|
|
103
|
+
// FUT-698): park the order and hand the buyer over, exactly as the card
|
|
104
|
+
// path does. `phase` stays as-is — the tab is navigating away.
|
|
105
|
+
if (charged.data.hostedCheckoutUrl) {
|
|
106
|
+
rememberHostedOrder(order);
|
|
107
|
+
navigate(charged.data.hostedCheckoutUrl);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
110
|
+
// A business outcome (declined → FAILED) shows the status screen; an
|
|
111
|
+
// accepted charge begins polling for the async confirmation.
|
|
112
|
+
if (charged.data.status !== "AWAITING_PAYMENT") {
|
|
113
|
+
onResolved(charged.data.status);
|
|
114
|
+
return charged.data.status === "PAID";
|
|
115
|
+
}
|
|
116
|
+
setPhase("polling");
|
|
117
|
+
return true;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
phase,
|
|
122
|
+
error,
|
|
123
|
+
errorCode,
|
|
124
|
+
unresolved: errorCode === UNRESOLVED_CODE,
|
|
125
|
+
pollError,
|
|
126
|
+
pollTimedOut,
|
|
127
|
+
payWithKey,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { Box, Divider } from "@mui/material";
|
|
2
|
+
import { useState, type JSX } from "react";
|
|
3
|
+
|
|
4
|
+
import { ApplePayButton, applePaySupported } from "./apple-pay-button";
|
|
5
|
+
import { CardView } from "./card-view";
|
|
6
|
+
import { GooglePayButton } from "./google-pay-button";
|
|
7
|
+
import {
|
|
8
|
+
applePayDeclared,
|
|
9
|
+
cardChain,
|
|
10
|
+
cardTokenization,
|
|
11
|
+
googlePayConfig,
|
|
12
|
+
} from "./method-capability";
|
|
13
|
+
import type { ProviderCheckoutScreenProps } from "./providers/types";
|
|
14
|
+
import { useCheckoutComponents } from "./ui";
|
|
15
|
+
import { useWalletCharge, type WalletCharge } from "./use-wallet-charge";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The CARD pane with its wallet fast lane (FUT-471/472).
|
|
19
|
+
*
|
|
20
|
+
* A wallet is not a fourth method — it is another way of producing the CARD
|
|
21
|
+
* instrument — so it renders INSIDE the card pane, above the form, and only
|
|
22
|
+
* when the chain head declared the wallet capability (both gates fail closed:
|
|
23
|
+
* `googlePayConfig` / `applePayDeclared` + the device's own support). A store
|
|
24
|
+
* with no wallet renders exactly the card view it always did.
|
|
25
|
+
*
|
|
26
|
+
* One pane owns BOTH submit paths' visibility so they cannot invite a double
|
|
27
|
+
* payment: while a wallet charge is in flight or being confirmed, the card
|
|
28
|
+
* form and every wallet button are REPLACED by the processing state — the same
|
|
29
|
+
* rule `card-view.tsx` applies to its own pay bar, one level up.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The screen props narrowed to a raised order — what this pane requires. */
|
|
33
|
+
type WalletPaneProps = ProviderCheckoutScreenProps & {
|
|
34
|
+
order: NonNullable<ProviderCheckoutScreenProps["order"]>;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Post-submit confirmation, error > timeout > spinner — the card view's order. */
|
|
38
|
+
function WalletProcessing({ wallet }: { wallet: WalletCharge }): JSX.Element {
|
|
39
|
+
const { Alert, LoadingState } = useCheckoutComponents();
|
|
40
|
+
if (wallet.pollError) {
|
|
41
|
+
return (
|
|
42
|
+
<Alert
|
|
43
|
+
variant="danger"
|
|
44
|
+
title="Não foi possível confirmar o pagamento"
|
|
45
|
+
description={wallet.pollError}
|
|
46
|
+
showIcon
|
|
47
|
+
data-testid="wallet-poll-error"
|
|
48
|
+
/>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (wallet.pollTimedOut) {
|
|
52
|
+
return (
|
|
53
|
+
<Alert
|
|
54
|
+
variant="warning"
|
|
55
|
+
title="O pagamento está demorando mais que o esperado"
|
|
56
|
+
description="Você pode aguardar ou verificar seu pedido em instantes — não realize um novo pagamento."
|
|
57
|
+
showIcon
|
|
58
|
+
data-testid="wallet-poll-timeout"
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
return (
|
|
63
|
+
<LoadingState
|
|
64
|
+
variant="spinner"
|
|
65
|
+
size="md"
|
|
66
|
+
message="Processando pagamento…"
|
|
67
|
+
dataTestId="wallet-processing"
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* An UNRESOLVED wallet charge (FUT-563): some provider may be holding the
|
|
74
|
+
* buyer's money, so the pane shows the warning and NO pay control of any kind
|
|
75
|
+
* — no wallet button, no card form. Same presentation rule as the card view's
|
|
76
|
+
* own unresolved state.
|
|
77
|
+
*/
|
|
78
|
+
function WalletUnresolved({ message }: { message: string }): JSX.Element {
|
|
79
|
+
const { Alert } = useCheckoutComponents();
|
|
80
|
+
return (
|
|
81
|
+
<Alert
|
|
82
|
+
variant="warning"
|
|
83
|
+
title="Estamos confirmando seu pagamento"
|
|
84
|
+
description={message}
|
|
85
|
+
showIcon
|
|
86
|
+
data-testid="wallet-unresolved"
|
|
87
|
+
/>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The wallet buttons the store's chain head supports, or null when there are
|
|
93
|
+
* none to offer. The divider renders only once SOMETHING sits above it: Apple
|
|
94
|
+
* availability is known synchronously (feature-detect), Google's arrives when
|
|
95
|
+
* `isReadyToPay` approves (`onReady`) — a bare "ou pague com cartão" with
|
|
96
|
+
* nothing above it would caption an empty space.
|
|
97
|
+
*/
|
|
98
|
+
function WalletButtons({
|
|
99
|
+
props,
|
|
100
|
+
wallet,
|
|
101
|
+
onSheetError,
|
|
102
|
+
}: {
|
|
103
|
+
props: WalletPaneProps;
|
|
104
|
+
wallet: WalletCharge;
|
|
105
|
+
onSheetError: (message: string) => void;
|
|
106
|
+
}): JSX.Element | null {
|
|
107
|
+
const { Text } = useCheckoutComponents();
|
|
108
|
+
const [googleReady, setGoogleReady] = useState(false);
|
|
109
|
+
const googlePay = googlePayConfig(props.config);
|
|
110
|
+
const applePay = applePayDeclared(props.config) && applePaySupported();
|
|
111
|
+
if (!googlePay && !applePay) return null;
|
|
112
|
+
return (
|
|
113
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 1.5 }}>
|
|
114
|
+
{applePay ? (
|
|
115
|
+
<ApplePayButton
|
|
116
|
+
order={props.order}
|
|
117
|
+
onAuthorized={(key) => wallet.payWithKey("APPLE_PAY", key)}
|
|
118
|
+
onError={onSheetError}
|
|
119
|
+
validateMerchant={props.validateApplePayMerchant}
|
|
120
|
+
/>
|
|
121
|
+
) : null}
|
|
122
|
+
{googlePay ? (
|
|
123
|
+
<GooglePayButton
|
|
124
|
+
order={props.order}
|
|
125
|
+
params={googlePay}
|
|
126
|
+
onKey={(key) => void wallet.payWithKey("GOOGLE_PAY", key)}
|
|
127
|
+
onError={onSheetError}
|
|
128
|
+
onReady={() => setGoogleReady(true)}
|
|
129
|
+
/>
|
|
130
|
+
) : null}
|
|
131
|
+
{applePay || googleReady ? (
|
|
132
|
+
<Divider>
|
|
133
|
+
<Text variant="caption" size="xs" color="secondary" as="span">
|
|
134
|
+
ou pague com cartão
|
|
135
|
+
</Text>
|
|
136
|
+
</Divider>
|
|
137
|
+
) : null}
|
|
138
|
+
</Box>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The CARD pane: wallet fast lane above, the card form below. */
|
|
143
|
+
export function WalletCardPane(props: WalletPaneProps): JSX.Element {
|
|
144
|
+
const { Alert } = useCheckoutComponents();
|
|
145
|
+
const { order, buyer, config, tenantSlug, onResolved, pollIntervalMs } = props;
|
|
146
|
+
const wallet = useWalletCharge(order, buyer, onResolved, pollIntervalMs);
|
|
147
|
+
// A sheet failure the wallet reported before any charge existed (pay.js
|
|
148
|
+
// refused, merchant validation unavailable, the sheet errored) — shown
|
|
149
|
+
// beside the form, which stays usable.
|
|
150
|
+
const [sheetError, setSheetError] = useState<string | null>(null);
|
|
151
|
+
|
|
152
|
+
if (wallet.phase !== "idle") return <WalletProcessing wallet={wallet} />;
|
|
153
|
+
if (wallet.unresolved) return <WalletUnresolved message={wallet.error ?? ""} />;
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
|
|
157
|
+
<WalletButtons props={props} wallet={wallet} onSheetError={setSheetError} />
|
|
158
|
+
{wallet.error ? (
|
|
159
|
+
<Alert
|
|
160
|
+
variant="danger"
|
|
161
|
+
title="Não foi possível pagar"
|
|
162
|
+
description={wallet.error}
|
|
163
|
+
showIcon
|
|
164
|
+
data-testid="wallet-error"
|
|
165
|
+
/>
|
|
166
|
+
) : null}
|
|
167
|
+
{sheetError && !wallet.error ? (
|
|
168
|
+
<Alert
|
|
169
|
+
variant="danger"
|
|
170
|
+
title="Não foi possível pagar"
|
|
171
|
+
description={sheetError}
|
|
172
|
+
showIcon
|
|
173
|
+
data-testid="wallet-sheet-error"
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
<CardView
|
|
177
|
+
order={order}
|
|
178
|
+
buyer={buyer}
|
|
179
|
+
providerConfig={cardTokenization(config)}
|
|
180
|
+
providerChain={cardChain(config)}
|
|
181
|
+
tenantSlug={tenantSlug}
|
|
182
|
+
onResolved={onResolved}
|
|
183
|
+
pollIntervalMs={pollIntervalMs}
|
|
184
|
+
/>
|
|
185
|
+
</Box>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Box, Button, Stack, Typography } from '@mui/material';
|
|
4
|
+
import type { ReactNode } from 'react';
|
|
5
|
+
|
|
6
|
+
import type { ConnectApplicationReport, PaymentEnvironment } from '@12-apps/payments-backend';
|
|
7
|
+
|
|
8
|
+
import { CARD_SX, ConnectEnvironmentCard } from './ConnectEnvironmentCard';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The platform's PagBank Connect application, per environment (FUT-479,
|
|
12
|
+
* packaged by FUT-573).
|
|
13
|
+
*
|
|
14
|
+
* The application every store authorizes against is registered by hand, so
|
|
15
|
+
* nothing in the product could say what is registered, in which environment,
|
|
16
|
+
* or with which redirect URI. This panel is the consult
|
|
17
|
+
* (`GET /oauth2/application/{client_id}`) made permanent: per environment —
|
|
18
|
+
* sandbox and produção are separate applications — it shows what PagBank has
|
|
19
|
+
* on file, including the exact redirect_uri, and flags a mismatch against the
|
|
20
|
+
* callback the deployment actually uses (a mismatch is a silent OAuth
|
|
21
|
+
* failure). Read-only: creating an application stays a deliberate manual act.
|
|
22
|
+
*
|
|
23
|
+
* Dumb by design: the HOST fetches the report from its own mounted route
|
|
24
|
+
* (`consultConnectApplications` in `@12-apps/payments-backend`) and passes it
|
|
25
|
+
* here, so the host page is a thin mount — page chrome, auth and loading
|
|
26
|
+
* belong to the host; the screen itself lives in this package.
|
|
27
|
+
*/
|
|
28
|
+
export interface ConnectApplicationPanelProps {
|
|
29
|
+
/** The consult report, as the backend's `consultConnectApplications` answers. */
|
|
30
|
+
report: ConnectApplicationReport;
|
|
31
|
+
/** Re-run the consult. Omitted, the refresh button is not rendered. */
|
|
32
|
+
onRefresh?: () => void;
|
|
33
|
+
/**
|
|
34
|
+
* Which host-side variables feed one environment's application — the host's
|
|
35
|
+
* own configuration surface, rendered as a collapsible per-environment help
|
|
36
|
+
* when provided.
|
|
37
|
+
*/
|
|
38
|
+
configVarsFor?: (environment: PaymentEnvironment) => string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function ConnectApplicationPanel(props: ConnectApplicationPanelProps): ReactNode {
|
|
42
|
+
const { report, onRefresh, configVarsFor } = props;
|
|
43
|
+
return (
|
|
44
|
+
<Stack spacing={2} data-testid="connect-application-panel">
|
|
45
|
+
<Stack spacing={0.5} data-testid="connect-expected-redirect" sx={CARD_SX}>
|
|
46
|
+
<Typography variant="caption" color="text.secondary" fontWeight={600}>
|
|
47
|
+
Callback desta instalação (o valor que precisa estar registrado)
|
|
48
|
+
</Typography>
|
|
49
|
+
<Box
|
|
50
|
+
component="code"
|
|
51
|
+
sx={{ fontFamily: 'monospace', fontSize: 13, wordBreak: 'break-all' }}
|
|
52
|
+
>
|
|
53
|
+
{report.expectedRedirectUri}
|
|
54
|
+
</Box>
|
|
55
|
+
</Stack>
|
|
56
|
+
{report.environments.map((status) => (
|
|
57
|
+
<ConnectEnvironmentCard
|
|
58
|
+
key={status.environment}
|
|
59
|
+
status={status}
|
|
60
|
+
configVars={configVarsFor?.(status.environment)}
|
|
61
|
+
/>
|
|
62
|
+
))}
|
|
63
|
+
{onRefresh ? (
|
|
64
|
+
<Box>
|
|
65
|
+
<Button
|
|
66
|
+
variant="outlined"
|
|
67
|
+
size="small"
|
|
68
|
+
onClick={() => onRefresh()}
|
|
69
|
+
data-testid="connect-refresh"
|
|
70
|
+
>
|
|
71
|
+
Consultar novamente
|
|
72
|
+
</Button>
|
|
73
|
+
</Box>
|
|
74
|
+
) : null}
|
|
75
|
+
</Stack>
|
|
76
|
+
);
|
|
77
|
+
}
|