Skip to content

Commit

Permalink
3ds
Browse files Browse the repository at this point in the history
  • Loading branch information
syvlabs committed Nov 16, 2024
1 parent 4498da4 commit 76c4218
Show file tree
Hide file tree
Showing 5 changed files with 127 additions and 44 deletions.
9 changes: 9 additions & 0 deletions packages/utils/src/cde-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ import {
PaymentFormPrefill,
PreviewCheckoutResponse,
SetupCheckoutResponse,
StartPaymentFlowForCCRequest,
StartPaymentFlowForCCResponse,
} from './cde_models';
import { sleep } from './stripe';
import { sum } from './math';
Expand Down Expand Up @@ -93,6 +95,13 @@ export const startPaymentFlow = async (
return await queryCDE(cdeConn, { type: 'start_payment_flow', payload }, PaymentFlowStartedEventPayload);
};

export const startPaymentFlowForCC = async (
cdeConn: CdeConnection,
payload: StartPaymentFlowForCCRequest
): Promise<StartPaymentFlowForCCResponse> => {
return await queryCDE(cdeConn, { type: 'start_payment_flow_for_cc', payload }, StartPaymentFlowForCCResponse);
};

export const confirmPaymentFlow = async (
cdeConn: CdeConnection,
payload: ConfirmPaymentFlowRequest
Expand Down
13 changes: 13 additions & 0 deletions packages/utils/src/cde_models.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { RequiredFormFields } from './shared-models';

export const nullOrUndefOr = <T extends z.ZodType>(zType: T): z.ZodNullable<z.ZodOptional<T>> =>
z.nullable(zType.optional());
Expand Down Expand Up @@ -217,9 +218,21 @@ export const StartPaymentFlowResponse = z.object({
});
export type StartPaymentFlowResponse = z.infer<typeof StartPaymentFlowResponse>;

// StartPaymentFlowForCCRequest
export const StartPaymentFlowForCCRequest = z.object({
session_id: z.string(),
non_cde_form_fields: RequiredFormFields,
checkout_payment_method: CheckoutPaymentMethod,
});
export type StartPaymentFlowForCCRequest = z.infer<typeof StartPaymentFlowForCCRequest>;

// StartPaymentFlowForCCResponse
export const StartPaymentFlowForCCResponse = z.object({
required_user_actions: z.array(z.record(z.string(), z.any())),
cc_pm_id: z.string(),
});
export type StartPaymentFlowForCCResponse = z.infer<typeof StartPaymentFlowForCCResponse>;

// GenericNextActionMetadata
export const GenericNextActionMetadata = z.record(z.any());
export type GenericNextActionMetadata = z.infer<typeof GenericNextActionMetadata>;
23 changes: 15 additions & 8 deletions packages/utils/src/flows/ojs-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,15 +117,22 @@ export const createOjsFlowLoggers = (
): {
log: typeof console.log;
err: typeof console.error;
log__: typeof console.log;
err__: typeof console.error;
} => {
const log: typeof console.log = (...args) => {
// Do this to prevent minification issues
window['console'].log(`[flow][${prefix}]`, ...args);
};
const err: typeof console.error = (...args) => {
// Do this to prevent minification issues
window['console'].error(`[flow][${prefix}]`, ...args);
};
return {
log: (...args) => {
// Do this to prevent minification issues
window['console'].log(`[flow][${prefix}]`, ...args);
},
err: (...args) => {
// Do this to prevent minification issues
window['console'].error(`[flow][${prefix}]`, ...args);
},
log,
err,
// Aliases
log__: log,
err__: err,
};
};
111 changes: 77 additions & 34 deletions packages/utils/src/flows/stripe/stripe-cc-flow.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { CdeError, checkoutCardElements, getPrefill, setupCheckout, tokenizeCard } from '../../cde-client';
import {
CdeError,
checkoutCardElements,
confirmPaymentFlow,
getPrefill,
setupCheckout,
startPaymentFlowForCC,
tokenizeCard,
} from '../../cde-client';
import { StartPaymentFlowForCCResponse } from '../../cde_models';
import {
AllFieldNames,
ConfirmPaymentFlowResponse,
FieldName,
RequiredFormFields,
TokenizeCardErrorResponse,
TokenizeCardResponse,
} from '../../shared-models';
import { launchStripe3DSDialogFlow, Stripe3DSNextActionMetadata } from '../../stripe';
import { extractIssuesPerField } from '../../zod-errors';
import {
addBasicCheckoutCallbackHandlers,
Expand All @@ -18,11 +29,11 @@ import {
// For gpay, apple pay, and stripe link:
// TODO ASAP override empty zip code logic
// if (!extraData[FieldName.ZIP_CODE]) {
// console.log('[flow] Overriding empty zip code (only for google pay, apple pay, and stripe link)');
// log__('[flow] Overriding empty zip code (only for google pay, apple pay, and stripe link)');
// extraData[FieldName.ZIP_CODE] = '00000';
// }

const { log, err } = createOjsFlowLoggers('stripe-cc');
const { log__, err__ } = createOjsFlowLoggers('stripe-cc');

/*
* Runs the main Stripe CC flow
Expand All @@ -32,54 +43,77 @@ export const runStripeCcFlow: RunOjsFlow = addBasicCheckoutCallbackHandlers(
const anyCdeConnection = Array.from(context.cdeConnections.values())[0];
const prefill = await getPrefill(anyCdeConnection);

log('Validating non-CDE form fields');
log__`Validating non-CDE form fields`;
const nonCdeFormFields = validateNonCdeFormFields(nonCdeFormInputs, flowCallbacks.onValidationError);

log('Tokenizing card info in CDE');
log__`Tokenizing card info in CDE`;
const tokenizeCardResults = await tokenizeCard(context.cdeConnections, {
session_id: context.elementsSessionId,
});
validateTokenizeCardResults(tokenizeCardResults, flowCallbacks.onValidationError);

const commonCheckoutParams = {
session_id: context.elementsSessionId,
checkout_payment_method: checkoutPaymentMethod,
non_cde_form_fields: nonCdeFormFields,
};

try {
// TODO ASAP: check if logs are actually logging
if (prefill.mode === 'setup') {
log('Setting up payment method in CDE');
const setupResult = await setupCheckout(anyCdeConnection, {
session_id: context.elementsSessionId,
checkout_payment_method: checkoutPaymentMethod,
non_cde_form_fields: nonCdeFormFields,
});
return {
mode: 'setup',
result: setupResult,
};
log__`Setting up payment method in CDE`;
const result = await setupCheckout(anyCdeConnection, commonCheckoutParams);
return { mode: 'setup', result };
} else {
log('Checking out card info in CDE');
const checkoutResult = await checkoutCardElements(anyCdeConnection, {
session_id: context.elementsSessionId,
checkout_payment_method: checkoutPaymentMethod,
non_cde_form_fields: nonCdeFormFields,
});
return {
mode: 'checkout',
result: checkoutResult,
};
log__`Checking out card info in CDE`;
const result = await checkoutCardElements(anyCdeConnection, commonCheckoutParams);
return { mode: 'checkout', result };
}
} catch (error) {
err('Error checking out card info in CDE:', error);
if (error instanceof CdeError) {
err('Got CDE error', error.originalErrorMessage);
if (error.originalErrorMessage === '3DS_REQUIRED') {
// TODO ASAP: do 3DS stuff here
// TODO ASAP: check out the 3DS flow in event.ts (3DS_REQUIRED)
log__`Card requires 3DS, starting non-legacy payment flow`;
const startPfResult = await startPaymentFlowForCC(anyCdeConnection, commonCheckoutParams);
const nextActionMetadata = parse3DSNextActionMetadata(startPfResult);

log__`Launching Stripe 3DS dialog flow`;
await launchStripe3DSDialogFlow(nextActionMetadata);

log__`Confirming payment flow`;
const confirmResult = await confirmPaymentFlow(anyCdeConnection, {
secure_token: prefill.token,
existing_cc_pm_id: startPfResult.cc_pm_id,
});
const createdPaymentMethod = parsePaymentFlowConfirmation(confirmResult);

if (prefill.mode === 'setup') {
return { mode: 'setup', result: createdPaymentMethod };
} else {
const result = await checkoutCardElements(anyCdeConnection, {
...commonCheckoutParams,
// Use the existing payment method ID from start_payment_flow
existing_cc_pm_id: startPfResult.cc_pm_id,
});
return { mode: 'checkout', result };
}
}
}
err__`Error checking out card info in CDE:`;
err__(error);
throw error;
}
}
);

/**
* Parses and validates the payment flow confirmation response
*/
const parsePaymentFlowConfirmation = (response: ConfirmPaymentFlowResponse): { payment_method_id: string } => {
if (response.payment_methods.length !== 1) {
throw new Error(`Expected exactly one payment method, got ${response.payment_methods.length}`);
}
return { payment_method_id: response.payment_methods[0].id };
};

/**
* Validates the non-CDE (non-sensitive) form fields
*/
Expand All @@ -94,7 +128,7 @@ const validateNonCdeFormFields = (
for (const [fieldName, errors] of Object.entries(issues)) {
onValidationError(fieldName as FieldName, errors, fieldName);
}
console.log('[flow][stripe-cc] Got validation errors in non-CDE form fields:', payload.data);
log__('[flow][stripe-cc] Got validation errors in non-CDE form fields:', payload.data);
throw new Error('Got validation errors in non-CDE form fields');
}

Expand All @@ -112,7 +146,7 @@ const validateTokenizeCardResults = (
if (tokenizeResult.success === false) {
// Validation errors can also come from CDE (from the sensitive fields)
onTokenizeResultValidationError(tokenizeResult, onValidationError);
console.log('[flow][stripe-cc] Error tokenizing card: got validation errors', tokenizeResult.errors);
log__('[flow][stripe-cc] Error tokenizing card: got validation errors', tokenizeResult.errors);
throw new Error('Got validation errors while tokenizing card');
}
}
Expand All @@ -128,11 +162,20 @@ const onTokenizeResultValidationError = (
errorResponse.errors.forEach((error) => {
const parsed = AllFieldNames.safeParse(error.elementType);
if (!parsed.success) {
console.error('[flow][stripe-cc] Unknown field name in onValidationError:', error.elementType);
err__('[flow][stripe-cc] Unknown field name in onValidationError:', error.elementType);
} else {
const fieldName = parsed.data;
onValidationError(fieldName, error.errors);
}
});
console.error('[flow][stripe-cc] Error tokenizing card:', errorResponse.errors);
err__('[flow][stripe-cc] Error tokenizing card:', errorResponse.errors);
};

const parse3DSNextActionMetadata = (response: StartPaymentFlowForCCResponse): Stripe3DSNextActionMetadata => {
if (response.required_user_actions.length !== 1) {
throw new Error(
`Error occurred.\nDetails: got ${response.required_user_actions.length} required user actions. Expecting only one action`
);
}
return Stripe3DSNextActionMetadata.parse(response.required_user_actions[0]);
};
15 changes: 13 additions & 2 deletions packages/utils/src/stripe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
Stripe as StripeType,
} from '@stripe/stripe-js';
import { Amount, CheckoutPaymentMethod, PaymentFlowStartedEventPayload } from './shared-models';
import { z } from 'zod';

export type StripeContext =
| {
Expand All @@ -17,6 +18,13 @@ export type StripeContext =
stripePubKey: undefined;
};

export const Stripe3DSNextActionMetadata = z.object({
stripe_pk: z.string(),
client_secret: z.string(),
stripe_pm_id: z.string(),
});
export type Stripe3DSNextActionMetadata = z.infer<typeof Stripe3DSNextActionMetadata>;

const ourCurrencyToTheirs: Record<string, string> = {
usd: 'usd',
brl: 'brl',
Expand Down Expand Up @@ -163,14 +171,17 @@ export const confirmPaymentFlowForStripeLink = async (payload: PaymentFlowStarte
};

export const confirmPaymentFlowFor3DS = async (payload: PaymentFlowStartedEventPayload): Promise<void> => {
const nextActionMetadata = payload.nextActionMetadata;
const nextActionMetadata = Stripe3DSNextActionMetadata.parse(payload.nextActionMetadata);
await launchStripe3DSDialogFlow(nextActionMetadata);
};

export const launchStripe3DSDialogFlow = async (nextActionMetadata: Stripe3DSNextActionMetadata): Promise<void> => {
const stripe = await getLoadedStripe(nextActionMetadata.stripe_pk);
const confirmResult = await stripe.confirmCardSetup(nextActionMetadata.client_secret, {
payment_method: nextActionMetadata.stripe_pm_id,
});
const resultStatus = confirmResult.setupIntent?.status;
if (resultStatus === 'succeeded') {
// Nice
console.log('[3DS] CONFIRMING PM:', nextActionMetadata.stripe_pm_id);
console.log('[3DS] Setup intent created:', confirmResult.setupIntent);
} else if (resultStatus === 'canceled') {
Expand Down

0 comments on commit 76c4218

Please sign in to comment.