Skip to content

Commit

Permalink
🪸 [OJS] OJS Setup PM for Payment Flows (#44)
Browse files Browse the repository at this point in the history
* fixes

* fix

* fixes

* bump ver
  • Loading branch information
syvlabs authored Sep 29, 2024
1 parent d7e18a6 commit 2040cc2
Show file tree
Hide file tree
Showing 9 changed files with 183 additions and 42 deletions.
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.

4 changes: 2 additions & 2 deletions example/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ const Form: FC<FormProps> = (props) => {
</button>

<button
onClick={() => applePay.startFlow()}
onClick={() => applePay.startFlow({ amountToDisplayForSetupMode: { amountAtom: 420, currency: 'usd' } })}
disabled={!applePay.isAvailable}
className={classNames(
'px-4 py-2 mt-2 w-full rounded-lg',
Expand All @@ -169,7 +169,7 @@ const Form: FC<FormProps> = (props) => {
</button>

<button
onClick={() => googlePay.startFlow()}
onClick={() => googlePay.startFlow({ amountToDisplayForSetupMode: { amountAtom: 420, currency: 'usd' } })}
disabled={!googlePay.isAvailable}
className={classNames(
'px-4 py-2 mt-2 w-full rounded-lg',
Expand Down
50 changes: 45 additions & 5 deletions lib/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { PaymentRequestPaymentMethodEvent } from '@stripe/stripe-js';
import { getErrorMessage } from '../utils/errors';
import { useCDEConnection } from '../utils/cde-connection';
import { isJsonString } from '../utils/types';
import { getPrefill, confirmPaymentFlow as confirmPaymentFlowInCDE } from '../utils/cde-client';

const ElementsForm: FC<ElementsFormProps> = (props) => {
const {
Expand Down Expand Up @@ -133,8 +134,11 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
if (!extraData) {
throw new Error(`extraData not populated`);
}
if (!cdeConn) {
throw new Error(`Not connected to CDE`);
}

const confirmPaymentFlow = async (): Promise<void> => {
const confirmPaymentFlow = async (): Promise<{ proceedToCheckout: boolean }> => {
const nextActionType = eventPayload.nextActionMetadata['type'];
console.log('[form] Confirm payment flow: next actions:', eventPayload.nextActionMetadata);
if (nextActionType === undefined) {
Expand All @@ -148,19 +152,51 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
// This is only applicable for PRs
throw new Error(`Stripe PM not set`);
}
console.log('[form] Confirming payment flow (Stripe PR');
console.log('[form] Confirming payment flow (Stripe PR)');
await confirmPaymentFlowForStripePR(eventPayload, stripePm);
} else {
throw new Error(`Unknown next action type: ${nextActionType}`);
}
const prefill = await getPrefill(cdeConn);
if (prefill.mode === 'setup') {
const { payment_methods } = await confirmPaymentFlowInCDE(cdeConn, {
secure_token: prefill.token,
existing_cc_pm_id: extraData.existingCCPMId,
});
if (payment_methods.length !== 1) {
throw new Error(`Expected exactly one payment method, got ${payment_methods.length}`);
}
console.log('[form] PF setup payment method complete:', payment_methods);
setPreventClose(false);
setTokenized(0);
setCheckoutFired(false);

if (onSetupPaymentMethodSuccess) {
onSetupPaymentMethodSuccess(payment_methods[0].id);
}
return {
proceedToCheckout: false,
};
} else {
// If not in setup mode, proceed to checkout
return {
proceedToCheckout: true,
};
}
};

confirmPaymentFlow()
.then(() => {
.then(({ proceedToCheckout }) => {
if (!proceedToCheckout) {
// TODO ASAP: log or something here
return;
}
console.log('[form] Starting checkout from payment flow.');

let existingCCPMId: string | undefined;
if (extraData.checkoutPaymentMethod.provider === 'credit_card') {
// Currently, if a credit card passes through this flow, it is 3DS
// In the future, we want to handle all CC flows here regardless of 3DS or not
existingCCPMId = eventPayload.startPFMetadata?.cc_pm_id;
if (!existingCCPMId) {
throw new Error(`CC PM ID not found`);
Expand Down Expand Up @@ -214,15 +250,18 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
setTokenized(0);
setCheckoutFired(false);

if (onCheckoutSuccess)
if (onCheckoutSuccess) {
onCheckoutSuccess(eventPayload.invoiceUrls, eventPayload.subscriptionIds, eventPayload.customerId);
}
} else if (eventType === EventType.enum.SETUP_PAYMENT_METHOD_SUCCESS) {
console.log('[form] Setup payment method complete:', eventPayload);
setPreventClose(false);
setTokenized(0);
setCheckoutFired(false);

if (onSetupPaymentMethodSuccess) onSetupPaymentMethodSuccess(eventPayload.paymentMethodId);
if (onSetupPaymentMethodSuccess) {
onSetupPaymentMethodSuccess(eventPayload.paymentMethodId);
}
} else if (eventType === EventType.enum.LOAD_ERROR) {
console.error('[form] Error loading iframe:', eventPayload.message);

Expand Down Expand Up @@ -287,6 +326,7 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
frameBaseUrl,
stripePm,
checkoutPaymentMethods,
cdeConn,
]
);

Expand Down
76 changes: 53 additions & 23 deletions lib/hooks/use-payment-requests.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { createStripePaymentRequest, parseStripePubKey, waitForUserToAddPaymentMethod } from '../utils/stripe';
import {
Amount,
CheckoutPaymentMethod,
EventType,
FieldName,
OptionalString,
PaymentRequestStartParams,
PaymentRequestStatus,
} from '../utils/shared-models';
import useMap from './use-map';
Expand All @@ -13,7 +15,7 @@ import { PaymentRequestPaymentMethodEvent } from '@stripe/stripe-js';
import { constructSubmitEventPayload, createInputsDictFromForm } from '../utils/event';
import { getErrorMessage } from '../utils/errors';
import { CdeConnection } from '../utils/cde-connection';
import { getCheckoutPreview } from '../utils/cde-client';
import { getCheckoutPreview, getPrefill } from '../utils/cde-client';
import { sum } from '../utils/math';

const PaymentRequestProvider = z.enum(['apple_pay', 'google_pay']);
Expand All @@ -27,7 +29,7 @@ const OUR_PROVIDER_TO_STRIPES: Record<PaymentRequestProvider, string> = {
const PR_LOADING: PaymentRequestStatus = {
isLoading: true,
isAvailable: false,
startFlow: () => {
startFlow: async () => {
console.warn(
`startFlow triggered while payment request is still not ready.
You can check the ".isLoading" param to know when a payment request is still loading,
Expand All @@ -39,7 +41,7 @@ const PR_LOADING: PaymentRequestStatus = {
const PR_ERROR: PaymentRequestStatus = {
isLoading: false,
isAvailable: false,
startFlow: () => {
startFlow: async () => {
console.error(`startFlow triggered when payment request is not available.`);
},
};
Expand Down Expand Up @@ -83,7 +85,7 @@ export const usePaymentRequests = (
setStatus.set(provider, {
isLoading: false,
isAvailable,
startFlow: () =>
startFlow: (params?: PaymentRequestStartParams) =>
startPaymentRequestUserFlow(
cdeConn,
secureToken,
Expand All @@ -92,7 +94,8 @@ export const usePaymentRequests = (
stripePubKey,
onUserCompleteUIFlow,
onValidationError,
onError
onError,
params
),
});
} catch (e) {
Expand Down Expand Up @@ -137,6 +140,24 @@ const checkIfProviderIsAvailable = async (stripePubKey: string, provider: Paymen
return canMakePayment[OUR_PROVIDER_TO_STRIPES[provider]];
};

const validateFormFields = (
formDiv: HTMLDivElement,
onValidationError: undefined | ((field: FieldName, errors: string[], elementId?: string) => void),
stripeXPrCpm: CheckoutPaymentMethod
): boolean => {
// TODO refactor validation out of this construct function later
const startPaymentFlowEvent = constructSubmitEventPayload(
EventType.enum.START_PAYMENT_FLOW,
// This is ok since we're just calling this function to use the validation function inside it
'dummy',
formDiv,
onValidationError ?? (() => {}),
stripeXPrCpm,
false
);
return !!startPaymentFlowEvent;
};

const startPaymentRequestUserFlow = async (
cdeConn: CdeConnection,
secureToken: string,
Expand All @@ -148,29 +169,38 @@ const startPaymentRequestUserFlow = async (
checkoutPaymentMethod: CheckoutPaymentMethod
) => void,
onValidationError: undefined | ((field: FieldName, errors: string[], elementId?: string) => void),
onError: (errMsg: string) => void
onError: (errMsg: string) => void,
prStartParams: PaymentRequestStartParams | undefined
): Promise<void> => {
try {
const formData = createInputsDictFromForm(formDiv, {});
if (onValidationError) {
// TODO refactor validation out of this construct function later
const startPaymentFlowEvent = constructSubmitEventPayload(
EventType.enum.START_PAYMENT_FLOW,
// This is ok since we're just calling this function to use the validation function inside it
'dummy',
formDiv,
onValidationError,
stripeXPrCpm,
false
);
if (!startPaymentFlowEvent) return;
if (!validateFormFields(formDiv, onValidationError, stripeXPrCpm)) {
return;
}
const promoCodeParsed = OptionalString.safeParse(formData[FieldName.PROMOTION_CODE]);
if (!promoCodeParsed.success) {
throw new Error(`Unknown promo code type: ${promoCodeParsed.error.message}`);
const prefill = await getPrefill(cdeConn);
const isSetupMode = prefill.mode === 'setup';
// TODO refactor this later\
let amountForPR: Amount;
if (isSetupMode) {
// TODO check later if there's a way to know currency in advance for setup mode
amountForPR = prStartParams?.amountToDisplayForSetupMode ?? { amountAtom: 0, currency: 'usd' };
} else {
if (prStartParams?.amountToDisplayForSetupMode) {
console.warn(`Warning: amountToDisplayForSetupMode passed in non-setup mode. This parameter will be ignored.`);
}
const promoCodeParsed = OptionalString.safeParse(formData[FieldName.PROMOTION_CODE]);
if (!promoCodeParsed.success) {
throw new Error(`Unknown promo code type: ${promoCodeParsed.error.message}`);
}
const checkoutPreview = await getCheckoutValue(cdeConn, secureToken, promoCodeParsed.data);
amountForPR = { amountAtom: checkoutPreview.amountAtom, currency: checkoutPreview.currency };
}
const { amountAtom, currency } = await getCheckoutValue(cdeConn, secureToken, promoCodeParsed.data);
const pr = await createStripePaymentRequest(stripePubKey, amountAtom, currency);
const pr = await createStripePaymentRequest(
stripePubKey,
amountForPR.amountAtom,
amountForPR.currency,
isSetupMode
);
await pr.canMakePayment(); // Required
pr.show();
const pmAddedEvent = await waitForUserToAddPaymentMethod(pr);
Expand Down
40 changes: 36 additions & 4 deletions lib/utils/cde-client.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,30 @@
import { z } from 'zod';
import { CdeConnection, CdeMessage } from './cde-connection';
import { CheckoutPreviewRequest } from './shared-models';
import { PreviewCheckoutResponse } from './cde_models';
import {
CheckoutPreviewRequest,
ConfirmPaymentFlowRequest,
ConfirmPaymentFlowResponse,
PaymentFlowStartedEventPayload,
SubmitEventPayload,
} from './shared-models';
import { CDEResponseError, PaymentFormPrefill, PreviewCheckoutResponse } from './cde_models';

const queryCDE = async <T extends z.ZodType>(
cdeConn: CdeConnection,
data: CdeMessage,
responseSchema: T
): Promise<z.infer<T>> => {
// Leaving these as commented out for easier debugging later
// console.log('[cde-client] Querying CDE with path and connection:', data.type, cdeConn);
console.log('[cde-client] Querying CDE with path and connection:', data.type, cdeConn);
const response = await cdeConn.send(data);
// console.log('[cde-client] Got response from CDE:', response);
if (isCDEResponseError(response)) {
throw new Error(`[cde-client] Error querying CDE: ${response.message}`);
}
console.log('[cde-client] Got response from CDE:', response);
if (!checkIfConformsToSchema(response, responseSchema)) {
const result = responseSchema.safeParse(response);
if (result.success) throw new Error('Invalid state');
console.error('OJS queryApi got a schema error. Expected schema:', responseSchema, 'Actual:', response);
throw result.error;
}
return response;
Expand All @@ -24,6 +34,10 @@ const checkIfConformsToSchema = <T extends z.ZodType>(value: unknown, schema: T)
return schema.safeParse(value).success;
};

const isCDEResponseError = (response: unknown): response is CDEResponseError => {
return CDEResponseError.safeParse(response).success;
};

// Endpoints start here

export const getCheckoutPreview = async (
Expand All @@ -32,3 +46,21 @@ export const getCheckoutPreview = async (
): Promise<PreviewCheckoutResponse> => {
return await queryCDE(cdeConn, { type: 'get_checkout_preview', payload: request }, PreviewCheckoutResponse);
};

export const getPrefill = async (cdeConn: CdeConnection): Promise<PaymentFormPrefill> => {
return await queryCDE(cdeConn, { type: 'get_prefill', payload: {} }, PaymentFormPrefill);
};

export const startPaymentFlow = async (
cdeConn: CdeConnection,
payload: SubmitEventPayload
): Promise<PaymentFlowStartedEventPayload> => {
return await queryCDE(cdeConn, { type: 'start_payment_flow', payload }, PaymentFlowStartedEventPayload);
};

export const confirmPaymentFlow = async (
cdeConn: CdeConnection,
payload: ConfirmPaymentFlowRequest
): Promise<ConfirmPaymentFlowResponse> => {
return await queryCDE(cdeConn, { type: 'confirm_payment_flow', payload }, ConfirmPaymentFlowResponse);
};
7 changes: 7 additions & 0 deletions lib/utils/cde_models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,13 @@ export const nullOrUndefOr = <T extends z.ZodType>(zType: T): z.ZodNullable<z.Zo

export const zStringReq = z.string().trim().min(1, { message: `Cannot be blank` });

// CDEResponseError
export const CDEResponseError = z.object({
cde_response_type: z.literal('error'),
message: z.string(),
});
export type CDEResponseError = z.infer<typeof CDEResponseError>;

// CurrencyEnum
export const CurrencyEnum = z.enum(['usd', 'brl']);
export type CurrencyEnum = z.infer<typeof CurrencyEnum>;
Expand Down
Loading

0 comments on commit 2040cc2

Please sign in to comment.