Skip to content

Commit

Permalink
🎟️ [OJS] Payment request coupon fixes (#43)
Browse files Browse the repository at this point in the history
* fixes

* wip

* fix

* cleanup

* cleanup

* cleanup

* ver bump
  • Loading branch information
syvlabs authored Sep 23, 2024
1 parent fd11f67 commit d7e18a6
Show file tree
Hide file tree
Showing 13 changed files with 494 additions and 46 deletions.
3 changes: 2 additions & 1 deletion example/package-lock.json

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

2 changes: 2 additions & 0 deletions lib/components/_common/frame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,9 @@ const ElementFrame: FC<ElementFrameProps> = (props) => {

return (
<iframe
id={`ojs-${subPath}-element`}
name={`${subPath}-element`}
className="ojs-iframe"
src={`${baseUrl}/app/v1/${subPath}-element/?${queryString}`}
style={frameStyle}
ref={iframeRef}
Expand Down
21 changes: 17 additions & 4 deletions lib/components/form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import { usePaymentRequests } from '../hooks/use-payment-requests';
import { confirmPaymentFlowFor3DS, confirmPaymentFlowForStripePR } from '../utils/stripe';
import { PaymentRequestPaymentMethodEvent } from '@stripe/stripe-js';
import { getErrorMessage } from '../utils/errors';
import { useCDEConnection } from '../utils/cde-connection';
import { isJsonString } from '../utils/types';

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

const { cdeConn, connectToCdeIframe } = useCDEConnection();

const onMessage = useCallback(
(event: MessageEvent) => {
// Since window.postMessage allows any source to post messages
Expand All @@ -68,7 +72,12 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
if (!event.source) return;
const eventSource = event.source;

const eventData = parseEventPayload(JSON.parse(event.data));
if (!isJsonString(event.data)) {
return;
}

const raw = JSON.parse(event.data);
const eventData = parseEventPayload(raw);
const elementId = eventData.elementId;
const targetFormId = eventData.formId;

Expand Down Expand Up @@ -352,9 +361,13 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
if (existingIframe) return;

console.log('[form] Registering iframe:', iframe);
if (iframes.length === 0) {
// Only do the first iframe
connectToCdeIframe(iframe);
}
setIframes((prevIframes) => [...prevIframes, iframe]);
},
[iframes]
[iframes, connectToCdeIframe]
);

const onUserCompletePaymentRequestUI = async (
Expand Down Expand Up @@ -403,8 +416,8 @@ const ElementsForm: FC<ElementsFormProps> = (props) => {
};

const paymentRequests = usePaymentRequests(
totalAmountAtoms,
currency,
cdeConn,
checkoutSecureToken,
checkoutPaymentMethods,
formRef.current,
onUserCompletePaymentRequestUI,
Expand Down
139 changes: 103 additions & 36 deletions lib/hooks/use-payment-requests.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
import { createStripePaymentRequest, parseStripePubKey, waitForUserToAddPaymentMethod } from '../utils/stripe';
import { CheckoutPaymentMethod, EventType, FieldName, PaymentRequestStatus } from '../utils/shared-models';
import {
CheckoutPaymentMethod,
EventType,
FieldName,
OptionalString,
PaymentRequestStatus,
} from '../utils/shared-models';
import useMap from './use-map';
import useAsyncEffect from 'use-async-effect';
import { z } from 'zod';
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 { sum } from '../utils/math';

const PaymentRequestProvider = z.enum(['apple_pay', 'google_pay']);
type PaymentRequestProvider = z.infer<typeof PaymentRequestProvider>;
Expand Down Expand Up @@ -36,8 +45,8 @@ const PR_ERROR: PaymentRequestStatus = {
};

export const usePaymentRequests = (
totalAmountAtom: number | undefined,
currency: string | undefined,
cdeConn: CdeConnection | null,
secureToken: string | undefined,
availableCPMs: CheckoutPaymentMethod[] | undefined,
formDiv: HTMLDivElement | null,
onUserCompleteUIFlow: (
Expand All @@ -51,7 +60,7 @@ export const usePaymentRequests = (
apple_pay: PR_LOADING,
google_pay: PR_LOADING,
});
const isLoading = totalAmountAtom === undefined || currency === undefined || availableCPMs === undefined || !formDiv;
const isLoading = secureToken === undefined || availableCPMs === undefined || !formDiv || !cdeConn;

// TODO: add more processors here once we have more processors supporting PaymentRequest API

Expand All @@ -63,46 +72,28 @@ export const usePaymentRequests = (
}
for (const provider of PaymentRequestProvider.options) {
const providerFriendlyName = provider.replace('_', '');
console.log(`Processing provider ${providerFriendlyName}`);
try {
const stripeXPrCpm = availableCPMs.find((cpm) => cpm.provider === provider && cpm.processor_name === 'stripe');
if (!stripeXPrCpm) {
throw new Error(`${providerFriendlyName} is not available as a checkout method`);
}
const stripePubKey = parseStripePubKey(stripeXPrCpm.metadata);
const pr = await createStripePaymentRequest(stripePubKey, totalAmountAtom, currency);
const canMakePayment = await pr.canMakePayment();
if (!canMakePayment) {
throw new Error(`Cannot make payment with ${providerFriendlyName} for this session`);
}
// Callback when payment request is finished
const startPaymentRequestUserFlow = async (): Promise<void> => {
try {
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;
}
pr.show();
const pmAddedEvent = await waitForUserToAddPaymentMethod(pr);
onUserCompleteUIFlow(pmAddedEvent, stripeXPrCpm);
} catch (e) {
console.error(e);
onError(getErrorMessage(e));
}
};
const isAvailable = await checkIfProviderIsAvailable(stripePubKey, provider);
setStatus.set(provider, {
isLoading: false,
isAvailable: canMakePayment[OUR_PROVIDER_TO_STRIPES[provider]],
startFlow: startPaymentRequestUserFlow,
isAvailable,
startFlow: () =>
startPaymentRequestUserFlow(
cdeConn,
secureToken,
formDiv,
stripeXPrCpm,
stripePubKey,
onUserCompleteUIFlow,
onValidationError,
onError
),
});
} catch (e) {
console.error(e);
Expand All @@ -113,3 +104,79 @@ export const usePaymentRequests = (

return status;
};

const getCheckoutValue = async (
cdeConn: CdeConnection,
secureToken: string,
promoCode: string | undefined
): Promise<{ currency: string; amountAtom: number }> => {
const checkoutPreview = await getCheckoutPreview(cdeConn, {
secure_token: secureToken,
promotion_code: promoCode,
});
const currencies = new Set(checkoutPreview.preview.invoices.map((inv) => inv.currency));
if (currencies.size !== 1) {
throw new Error(`Expected exactly one currency, got ${currencies.size}`);
}
const currency = currencies.values().next().value;
const amountAtom = sum(checkoutPreview.preview.invoices.map((inv) => inv.remaining_amount_atom));
return {
currency,
amountAtom,
};
};

const checkIfProviderIsAvailable = async (stripePubKey: string, provider: PaymentRequestProvider): Promise<boolean> => {
const DUMMY_AMOUNT_ATOM = 1000; // Just to check if PR is available
const testerPR = await createStripePaymentRequest(stripePubKey, DUMMY_AMOUNT_ATOM, 'usd');
const canMakePayment = await testerPR.canMakePayment();
testerPR.abort();
if (!canMakePayment) {
throw new Error(`Cannot make payment with ${provider} for this session`);
}
return canMakePayment[OUR_PROVIDER_TO_STRIPES[provider]];
};

const startPaymentRequestUserFlow = async (
cdeConn: CdeConnection,
secureToken: string,
formDiv: HTMLDivElement,
stripeXPrCpm: CheckoutPaymentMethod,
stripePubKey: string,
onUserCompleteUIFlow: (
stripePm: PaymentRequestPaymentMethodEvent,
checkoutPaymentMethod: CheckoutPaymentMethod
) => void,
onValidationError: undefined | ((field: FieldName, errors: string[], elementId?: string) => void),
onError: (errMsg: string) => void
): 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;
}
const promoCodeParsed = OptionalString.safeParse(formData[FieldName.PROMOTION_CODE]);
if (!promoCodeParsed.success) {
throw new Error(`Unknown promo code type: ${promoCodeParsed.error.message}`);
}
const { amountAtom, currency } = await getCheckoutValue(cdeConn, secureToken, promoCodeParsed.data);
const pr = await createStripePaymentRequest(stripePubKey, amountAtom, currency);
await pr.canMakePayment(); // Required
pr.show();
const pmAddedEvent = await waitForUserToAddPaymentMethod(pr);
onUserCompleteUIFlow(pmAddedEvent, stripeXPrCpm);
} catch (e) {
console.error(e);
onError(getErrorMessage(e));
}
};
34 changes: 34 additions & 0 deletions lib/utils/cde-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { z } from 'zod';
import { CdeConnection, CdeMessage } from './cde-connection';
import { CheckoutPreviewRequest } from './shared-models';
import { 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);
const response = await cdeConn.send(data);
// 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');
throw result.error;
}
return response;
};

const checkIfConformsToSchema = <T extends z.ZodType>(value: unknown, schema: T): value is T => {
return schema.safeParse(value).success;
};

// Endpoints start here

export const getCheckoutPreview = async (
cdeConn: CdeConnection,
request: CheckoutPreviewRequest
): Promise<PreviewCheckoutResponse> => {
return await queryCDE(cdeConn, { type: 'get_checkout_preview', payload: request }, PreviewCheckoutResponse);
};
87 changes: 87 additions & 0 deletions lib/utils/cde-connection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { CallSender, Connection, connectToChild } from 'penpal';
import { useState } from 'react';
import useAsyncEffect from 'use-async-effect';

export type CdeMessage = {
type: string;
} & Record<string, unknown>;

export type CdeConnection = {
send: (data: CdeMessage) => Promise<unknown>;
};

type HookReturnType = {
cdeConn: CdeConnection | null;
connectToCdeIframe: (iframe: HTMLIFrameElement) => Promise<void>;
};

type ConnectionStatus =
| {
status: 'none';
}
| {
status: 'connecting';
}
| {
status: 'waiting';
conn: Connection<CallSender>;
}
| {
status: 'connected';
conn: CdeConnection;
};

export const useCDEConnection = (): HookReturnType => {
const [childConn, setChildConn] = useState<ConnectionStatus>({ status: 'none' });

const connectToCdeIframe = async (iframe: HTMLIFrameElement): Promise<void> => {
if (childConn.status !== 'none') {
console.warn('registerIframe called more than once');
return;
}
console.log('Connecting to CDE iframe...', iframe);
setChildConn({ status: 'connecting' });
const conn = connectToChild({
iframe,
debug: true,
});
setChildConn({ status: 'waiting', conn });
};

// Wait for connection
useAsyncEffect(async () => {
if (childConn.status !== 'waiting') return;
const child: unknown = await childConn.conn.promise;
const isValidConnObject = await checkIfValidCdeConnectionObject(child);
if (!isResultValid(child, isValidConnObject)) {
throw new Error(`Got invalid CDE connection object`);
}
setChildConn({ status: 'connected', conn: child });
}, [childConn.status]);

return {
cdeConn: childConn.status === 'connected' ? childConn.conn : null,
connectToCdeIframe,
};
};

const checkIfValidCdeConnectionObject = async (obj: unknown): Promise<boolean> => {
if (typeof obj !== 'object' || !obj) return false;
try {
const ping: CdeMessage = { type: 'ping' };
// @ts-expect-error `send` typing
const result = await obj.send(ping);
if (result !== true) {
throw new Error(`Expected 'true' after ping, got ${JSON.stringify(result)}`);
}
return true;
} catch (e) {
console.error(`Invalid CDE connection check:`, e);
return false;
}
};

// Work around as an async type guard
const isResultValid = (obj: unknown, isValid: boolean): obj is CdeConnection => {
return !!obj && isValid;
};
Loading

0 comments on commit d7e18a6

Please sign in to comment.