Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

💳 Openpay JS Stripe 3DS support #34

Merged
merged 7 commits into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion example/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 8 additions & 3 deletions example/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,14 @@ const Form: FC<FormProps> = (props) => {
{({ submit, applePay, googlePay }) => (
<FormWrapper error={validationError}>
{(loading || overlayMessage) && (
<div className="absolute top-0 left-0 z-50 w-full h-full flex flex-col gap-2 items-center justify-center bg-emerald-100/50 dark:bg-emerald-800/50 backdrop-blur rounded-lg cursor-not-allowed">
{loading && <span className="text-4xl animate-spin">⏳︎</span>}
<p className="text-lg text-center font-bold max-w-md w-full">{overlayMessage ?? 'Loading...'}</p>
<div
data-testid="overlay"
className="w-full py-2 my-2 h-full flex flex-col gap-2 items-center justify-center bg-emerald-100/50 dark:bg-emerald-800/50 backdrop-blur rounded-lg cursor-not-allowed"
>
{loading && <span className="text-xl animate-spin">⏳︎</span>}
<pre data-opid="overlay-message" className="block font-bold max-w-md w-full text-wrap my-3">
{JSON.stringify(overlayMessage ?? '', null, 2)}
</pre>
</div>
)}

Expand Down
87 changes: 75 additions & 12 deletions lib/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { CheckoutPaymentMethod, EventType, SubmitEventPayload } from '../utils/s
import { FRAME_BASE_URL } from '../utils/constants';
import { v4 as uuidv4 } from 'uuid';
import { usePaymentRequests } from '../hooks/use-payment-requests';
import { confirmPaymentFlowForStripePR } from '../utils/stripe';
import { confirmPaymentFlowFor3DS, confirmPaymentFlowForStripePR } from '../utils/stripe';
import { PaymentRequestPaymentMethodEvent } from '@stripe/stripe-js';
import { getErrorMessage } from '../utils/errors';

Expand Down Expand Up @@ -50,6 +50,11 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
const formId = useMemo(() => `opjs-form-${uuidv4()}`, []);
const formRef = useRef<HTMLDivElement | null>(null);

// const start3dsCardPaymentFlow = async (checkoutPaymentMethod: CheckoutPaymentMethod): Promise<void> => {
// if (!formRef.current || !onValidationError || !sessionId || !checkoutPaymentMethods) return;

// };

const onMessage = useCallback(
(event: MessageEvent) => {
// Since window.postMessage allows any source to post messages
Expand Down Expand Up @@ -118,17 +123,47 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {

if (onCheckoutStarted) onCheckoutStarted();
} else if (eventType === EventType.enum.PAYMENT_FLOW_STARTED) {
if (!stripePm) {
throw new Error(`Stripe PM not set`);
}
if (!extraData) {
throw new Error(`extraData not populated`);
}
console.log('[form] Confirming payment flow');
confirmPaymentFlowForStripePR(eventPayload, stripePm)

const confirmPaymentFlow = async (): Promise<void> => {
const nextActionType = eventPayload.nextActionMetadata['type'];
if (nextActionType === undefined) {
// Nothing to do
} else if (nextActionType === 'stripe_3ds') {
await confirmPaymentFlowFor3DS(eventPayload);
} else if (nextActionType === 'stripe_payment_request') {
if (!stripePm) {
// This is only applicable for PRs
throw new Error(`Stripe PM not set`);
}
console.log('[form] Confirming payment flow');
await confirmPaymentFlowForStripePR(eventPayload, stripePm);
} else {
throw new Error(`Unknown next action type: ${nextActionType}`);
}
};

confirmPaymentFlow()
.then(() => {
console.log('[form] Starting checkout from payment flow.');
emitEvent(eventSource, formId, elementId, { ...extraData, type: 'CHECKOUT' }, frameBaseUrl);

let existingCCPMId: string | undefined;
if (extraData.checkoutPaymentMethod.provider === 'credit_card') {
existingCCPMId = eventPayload.startPFMetadata?.cc_pm_id;
if (!existingCCPMId) {
throw new Error(`CC PM ID not found`);
}
}

emitEvent(
eventSource,
formId,
elementId,
{ ...extraData, type: 'CHECKOUT', doNotUseLegacyCCFlow: true, existingCCPMId },
frameBaseUrl
);
setCheckoutFired(true);
setExtraData(undefined);
if (onCheckoutStarted) onCheckoutStarted();
Expand Down Expand Up @@ -182,10 +217,35 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
}
} else if (eventType === EventType.enum.TOKENIZE_ERROR || eventType === EventType.enum.CHECKOUT_ERROR) {
console.error('[form] API error from element:', eventPayload.message);
setPreventClose(false);
setCheckoutFired(false);

if (onCheckoutError) onCheckoutError(eventPayload.message);
if (eventPayload.message === '3DS_REQUIRED') {
// TODO refactor later
const cardCpm = checkoutPaymentMethods?.find((cpm) => cpm.provider === 'credit_card');
if (!sessionId || !formRef.current || !onValidationError || !cardCpm) return;
// Try all iframe targets, note that this loop will break as soon as one succeeds
for (const [elementId, target] of Object.entries(eventTargets)) {
if (!target) continue;
const startPaymentFlowEvent = constructSubmitEventPayload(
EventType.enum.START_PAYMENT_FLOW,
sessionId,
formRef.current,
onValidationError,
// Only stripe supports frontend 3DS right now,
// so we pass processor_name: 'stripe' to tell delegator to only use stripe
{ ...cardCpm, processor_name: 'stripe' },
false
);
if (!startPaymentFlowEvent) continue;
setCheckoutFired(true);
setExtraData(startPaymentFlowEvent);
emitEvent(target, formId, elementId, startPaymentFlowEvent, frameBaseUrl);
// If first one succeeds, break
break;
}
} else {
setPreventClose(false);
setCheckoutFired(false);
if (onCheckoutError) onCheckoutError(eventPayload.message);
}
}
},
[
Expand All @@ -207,6 +267,7 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
onValidationError,
frameBaseUrl,
stripePm,
checkoutPaymentMethods,
]
);

Expand All @@ -223,7 +284,8 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
sessionId,
formRef.current,
onValidationError,
cardCpm
cardCpm,
false
);
if (!extraData) return;

Expand Down Expand Up @@ -296,6 +358,7 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
formRef.current,
onValidationError,
checkoutPaymentMethod,
false,
paymentFlowMetadata
);
if (!startPaymentFlowEvent) continue;
Expand Down
3 changes: 2 additions & 1 deletion lib/hooks/use-payment-requests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,8 @@ export const usePaymentRequests = (
'dummy',
formDiv,
onValidationError,
stripeXPrCpm
stripeXPrCpm,
false
);
if (!startPaymentFlowEvent) return;
}
Expand Down
2 changes: 2 additions & 0 deletions lib/utils/event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,15 @@ export const constructSubmitEventPayload = (
formDiv: HTMLDivElement,
onValidationError: (field: FieldName, errors: string[], elementId?: string) => void,
checkoutPaymentMethod: CheckoutPaymentMethod,
doNotUseLegacyCCFlow: boolean,
paymentFlowMetadata?: Record<string, unknown>
): SubmitEventPayload | null => {
const extraData = createInputsDictFromForm(formDiv, {
type: eventType,
sessionId,
checkoutPaymentMethod,
paymentFlowMetadata,
doNotUseLegacyCCFlow,
});

console.log(`[form] Constructing ${eventType} payload:`, extraData);
Expand Down
3 changes: 3 additions & 0 deletions lib/utils/shared-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ export const PaymentFlowStartedEventPayload = z.object({
type: z.literal(EventType.enum.PAYMENT_FLOW_STARTED),
nextActionMetadata: z.record(z.string(), z.any()),
paymentFlowMetadata: z.any().optional(),
startPFMetadata: z.optional(z.record(z.string(), z.any())),
});
export type PaymentFlowStartedEventPayload = z.infer<typeof PaymentFlowStartedEventPayload>;

Expand All @@ -188,6 +189,8 @@ export const SubmitEventPayload = z
sessionId: RequiredString,
checkoutPaymentMethod: CheckoutPaymentMethod,
paymentFlowMetadata: z.any().optional(),
doNotUseLegacyCCFlow: z.boolean(),
existingCCPMId: OptionalString,
})
.extend(RequiredFormFields.shape);
export type SubmitEventPayload = z.infer<typeof SubmitEventPayload>;
Expand Down
18 changes: 18 additions & 0 deletions lib/utils/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,3 +113,21 @@ export const confirmPaymentFlowForStripePR = async (
stripePm.complete('success');
}
};

export const confirmPaymentFlowFor3DS = async (payload: PaymentFlowStartedEventPayload): Promise<void> => {
const nextActionMetadata = payload.nextActionMetadata;
const stripe = getLoadedStripe(nextActionMetadata.stripe_pk);
const confirmResult = await stripe.confirmCardSetup(nextActionMetadata.client_secret, {
payment_method: nextActionMetadata.stripe_pm_id,
});
console.log('[3DS] CONFIRMING PM:', nextActionMetadata.stripe_pm_id);
const resultStatus = confirmResult.setupIntent?.status;
if (resultStatus === 'succeeded') {
// Nice
console.log('[3DS] Setup intent created:', confirmResult.setupIntent);
} else if (resultStatus === 'canceled') {
throw new Error(`Payment cancelled, please click Submit again to pay`);
} else {
throw new Error(`Payment error: ${confirmResult.setupIntent?.last_setup_error?.message ?? 'unknown'}`);
}
};