Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
8 changes: 7 additions & 1 deletion docs/apm-ui-system.md
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,12 @@ class PaymentFormView extends APMViewImpl<{}, {

render() {
const { div, input, button, label } = elements;

let buttonText = 'Continue payment'

if (loading) {
buttonText = 'Processing...';
}

return div({ className: 'payment-form' },
div({ className: 'form-group' },
Expand Down Expand Up @@ -500,7 +506,7 @@ class PaymentFormView extends APMViewImpl<{}, {
onclick: this.handleSubmit,
disabled: this.state.loading || !this.state.email || !this.state.agreedToTerms
},
this.state.loading ? 'Processing...' : 'Continue Payment'
buttonText
)
);
}
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "processout.js",
"version": "1.2.3",
"version": "1.2.4",
"description": "ProcessOut.js is a JavaScript library for ProcessOut's payment processing API.",
"scripts": {
"build:processout": "tsc -p src/processout && uglifyjs --compress --keep-fnames --ie8 dist/processout.js -o dist/processout.js",
Expand Down
68 changes: 42 additions & 26 deletions src/apm/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -238,7 +238,7 @@ module ProcessOut {
}

ContextImpl.context.logger.error({
host: window?.location?.host ?? '',
host: window && window.location && window.location.host || '',
fileName: 'API.ts',
lineNumber: 208,
message,
Expand All @@ -254,14 +254,14 @@ module ProcessOut {
}
};

options.onFailure?.(defaultError);
options.onFailure && options.onFailure(defaultError);
return
}

switch (data.error_type) {
case 'request.route-not-found':
ContextImpl.context.logger.error({
host: window?.location?.host ?? '',
host: window && window.location && window.location.host || '',
fileName: 'API.ts',
lineNumber: 208,
message: `${request} failed as route does not exist`,
Expand All @@ -277,11 +277,11 @@ module ProcessOut {
}
};

options.onFailure?.(routeNotFoundError);
options.onFailure && options.onFailure(routeNotFoundError);
break;
default: {
ContextImpl.context.logger.error({
host: window?.location?.host ?? '',
host: window && window.location && window.location.host || '',
fileName: 'API.ts',
lineNumber: 208,
message: `${request} failed because of an error: ${data.message}`,
Expand All @@ -297,7 +297,7 @@ module ProcessOut {
}
};

options.onFailure?.(defaultError);
options.onFailure && options.onFailure(defaultError);
break;
}
}
Expand All @@ -310,7 +310,12 @@ module ProcessOut {
public static initialise(options: APIOptions<AuthorizationSuccessResponse | TokenizationSuccessResponse, AuthorizationValidationResponse | TokenizationValidationResponse>) {
const context = ContextImpl.context;
const flow = context.flow;
const source = flow === 'authorization' ? context.customerTokenId : undefined;
let source = undefined;

if (flow === 'authorization') {
source = context.customerTokenId;
}


return this.post({
gateway_configuration_id: context.gatewayConfigurationId,
Expand All @@ -324,7 +329,11 @@ module ProcessOut {
public static getCurrentStep(options: APIOptions<AuthorizationSuccessResponse | TokenizationSuccessResponse, AuthorizationValidationResponse | TokenizationValidationResponse>) {
const context = ContextImpl.context;
const flow = context.flow;
const source = flow === 'authorization' ? context.customerTokenId : undefined;
let source = undefined;

if (flow === 'authorization') {
source = context.customerTokenId;
}

return this.post({
gateway_configuration_id: context.gatewayConfigurationId,
Expand Down Expand Up @@ -389,12 +398,14 @@ module ProcessOut {
const context = ContextImpl.context;

// Build endpoint based on flow type
let endpoint = context.flow === 'authorization'
? ['invoices', context.invoiceId, 'apm-payment', path].filter(part => !!part).join('/')
: ['customers', context.customerId, 'apm-tokens', context.customerTokenId, 'tokenize'].join('/');
let endpoint = ['customers', context.customerId, 'apm-tokens', context.customerTokenId, 'tokenize'].join('/');

if (context.flow === 'authorization') {
endpoint = ['invoices', context.invoiceId, 'apm-payment', path].filter(part => !!part).join('/')

if (context.customerTokenId && context.flow === 'authorization' && method === 'GET') {
endpoint += `?source=${context.customerTokenId}`
if (context.customerTokenId && method === 'GET') {
endpoint += `?source=${context.customerTokenId}`
}
}

ContextImpl.context.poClient.apiRequest(
Expand All @@ -416,10 +427,12 @@ module ProcessOut {
}

// Handle validation responses based on flow type
const isValidation = context.flow === 'authorization'
? isValidationResponse(apiResponse as AuthorizationNetworkResponse)
: isTokenizationValidationResponse(apiResponse as TokenizationNetworkResponse);
let isValidation = isTokenizationValidationResponse(apiResponse as TokenizationNetworkResponse);

if (context.flow === 'authorization') {
isValidation = isValidationResponse(apiResponse as AuthorizationNetworkResponse);
}

if (isValidation) {
INITIAL_MAX_RETRIES = 0;

Expand All @@ -437,7 +450,7 @@ module ProcessOut {
error: {
code: 'processout-js.apm.validation-error',
message: 'Validation error',
invalid_fields: (apiResponse as any).invalid_fields || Object.keys((apiResponse as any).error?.parameters || {}).reduce((acc, name) => {
invalid_fields: (apiResponse as any).invalid_fields || Object.keys((apiResponse as any).error && (apiResponse as any).error.parameters || {}).reduce((acc, name) => {
acc.push({
name,
message: (apiResponse as any).error.parameters[name].detail
Expand All @@ -449,7 +462,7 @@ module ProcessOut {

// Add all payment fields (PaymentContext + payment data)
const errorWithPaymentData = this.addPaymentFields(errorData, apiResponse);
internalOptions.onError?.(errorWithPaymentData as any);
internalOptions.onError && internalOptions.onError(errorWithPaymentData as any);
return;
}

Expand Down Expand Up @@ -480,7 +493,7 @@ module ProcessOut {

// Include payment data in timeout error
const timeoutErrorWithPaymentData = this.addPaymentFields(timeoutError, apiResponse);
internalOptions.onFailure?.(timeoutErrorWithPaymentData);
internalOptions.onFailure && internalOptions.onFailure(timeoutErrorWithPaymentData);
return;
}
}
Expand All @@ -493,7 +506,7 @@ module ProcessOut {
internalOptions.hasReturnedFirstPending = true;
}

internalOptions.onSuccess?.(this.transformResponse(apiResponse));
internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse(apiResponse));
if (ContextImpl.context.confirmation.requiresAction && !storage.get('pending.startTime')) {
INITIAL_MAX_RETRIES = 0;
return
Expand Down Expand Up @@ -528,7 +541,7 @@ module ProcessOut {
}

if (apiResponse.state === 'NEXT_STEP_REQUIRED' && apiResponse.redirect) {
internalOptions.onSuccess?.(this.transformResponse(
internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse(
{
...apiResponse,
state: 'REDIRECT',
Expand All @@ -537,7 +550,7 @@ module ProcessOut {
return
}

internalOptions.onSuccess?.(this.transformResponse(apiResponse));
internalOptions.onSuccess && internalOptions.onSuccess(this.transformResponse(apiResponse));
return;
},
(req, _, errorCode) => {
Expand Down Expand Up @@ -568,10 +581,13 @@ module ProcessOut {
};

// Include payment data in network error if available
const networkErrorWithPaymentData = req.response
? this.addPaymentFields(networkError, req.response)
: networkError;
internalOptions.onFailure?.(networkErrorWithPaymentData);
let networkErrorWithPaymentData = networkError;

if (req.response) {
networkErrorWithPaymentData = this.addPaymentFields(networkError, req.response)
}

internalOptions.onFailure && internalOptions.onFailure(networkErrorWithPaymentData);
}
);
}
Expand Down
38 changes: 27 additions & 11 deletions src/apm/Page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,17 @@ module ProcessOut {
return;
}

let hasConfirmedPending = true;

if (ContextImpl.context.confirmation.requiresAction) {
hasConfirmedPending = this.state === "PENDING"
}

(request.bind(APIImpl) as APIRequest)({
hasConfirmedPending: ContextImpl.context.confirmation.requiresAction
? this.state === "PENDING"
: true,
hasConfirmedPending,
onSuccess: ({ elements, ...config }) => {
this.state = config.state
callback?.(null, this.state);
callback && callback(null, this.state);

if (config.state === 'REDIRECT') {
ContextImpl.context.page.render(APMViewRedirect, { elements, config: config as APIRedirectBase & Partial<PaymentContext> })
Expand All @@ -74,7 +78,7 @@ module ProcessOut {
},
onError: ({ elements, ...config }) => {
this.state = config.state
callback?.(config.error);
callback && callback(config.error);
ContextImpl.context.page.render(APMViewNextSteps, { elements, config })
},
onFailure: data => {
Expand Down Expand Up @@ -122,7 +126,7 @@ module ProcessOut {
loadScript(name: string, path: string, callback?: (error?: Error) => void): void {
// Check if script is already loaded
if (this.loadedScripts.get(name)) {
callback?.();
callback && callback();
return;
}

Expand All @@ -143,7 +147,7 @@ module ProcessOut {
// Check if script already exists in the document
if (document.querySelector(`script[src*="${name}"]`)) {
this.loadedScripts.set(name, true);
callback?.();
callback && callback();
return;
}

Expand All @@ -152,7 +156,14 @@ module ProcessOut {

// Create and load the script
const script = document.createElement('script');
script.src = path.startsWith('https://') ? path : ContextImpl.context.poClient.endpoint("js", path);

let scriptPath = path;

if (!path.startsWith('https://')) {
scriptPath = ContextImpl.context.poClient.endpoint("js", path);
}

script.src = scriptPath;

script.onload = () => {
this.loadedScripts.set(name, true);
Expand Down Expand Up @@ -222,18 +233,23 @@ module ProcessOut {
// --- Create New Wrapper based on support ---
if (!supportsShadowDOM) {
// Fallback: Use an iframe if Shadow DOM is not supported
const height = container.getBoundingClientRect().height;
let height = container.getBoundingClientRect().height;
const iframe = document.createElement('iframe');

if (height < 400) {
height = 400;
}

iframe.setAttribute('frameBorder', '0');
iframe.style.width = '100%';
iframe.style.height = height < 400 ? '400px' : height + 'px';
iframe.style.height = height + 'px';
iframe.title = 'Content Wrapper'; // Good practice for accessibility

container.appendChild(iframe); // Append iframe directly to the user's container

// Setup iframe content after it's loaded to avoid race conditions
const setupIframeContent = () => {
const doc = iframe.contentDocument ?? iframe.contentWindow?.document;
const doc = iframe.contentDocument || iframe.contentWindow && iframe.contentWindow.document;

if (doc) {
// Ensure the iframe has a basic HTML structure if it's not fully loaded
Expand Down
51 changes: 34 additions & 17 deletions src/apm/StateManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,13 @@ module ProcessOut {
* Get component state by ID
*/
getComponentState<T = any>(id: string): T | null {
const component = this.componentStates[id];
return component ? component.data : null;
let component = this.componentStates[id] as T;

if (!component) {
component = null
}

return component;
}

/**
Expand All @@ -120,9 +125,11 @@ module ProcessOut {
}

// Calculate new state
const updatedState = typeof newState === 'function'
? (newState as (prevState: T) => T)(component.data)
: newState;
let updatedState = newState;

if (typeof updatedState === 'function') {
updatedState = (newState as (prevState: T) => T)(component.data)
}

// Check if state actually changed (shallow comparison)
const stateChanged = forceUpdate || !this.shallowEqual(component.data, updatedState);
Expand Down Expand Up @@ -164,10 +171,12 @@ module ProcessOut {
this.isBatchScheduled = true;

// Use requestAnimationFrame to batch updates, with fallback for IE 11
const scheduleFunction = (typeof requestAnimationFrame !== 'undefined')
? requestAnimationFrame
: function(callback: () => void) { setTimeout(callback, 16); };

let scheduleFunction = requestAnimationFrame;

if (!scheduleFunction) {
scheduleFunction = function(callback: FrameRequestCallback) { return setTimeout(() => callback(performance.now()), 16); };
}

scheduleFunction(() => {
this.processBatchUpdate();
});
Expand Down Expand Up @@ -205,10 +214,12 @@ module ProcessOut {
this.pendingCallbacks.length = 0;

if (callbacks.length > 0) {
const scheduleFunction = (typeof requestAnimationFrame !== 'undefined')
? requestAnimationFrame
: function(callback: () => void) { setTimeout(callback, 16); };

let scheduleFunction = requestAnimationFrame

if (!scheduleFunction) {
scheduleFunction = function(callback: FrameRequestCallback) { return setTimeout(() => callback(performance.now()), 16); };
}

scheduleFunction(() => {
for (let i = 0; i < callbacks.length; i++) {
try {
Expand Down Expand Up @@ -595,7 +606,11 @@ module ProcessOut {

// Find the next available collision number
let collisionNum = viewCounters[baseHash];
let candidateId = collisionNum === 0 ? baseHash : `${baseHash}-${collisionNum}`;
let candidateId = `${baseHash}-${collisionNum}`;

if (collisionNum === 0) {
candidateId = baseHash;
}

// If this collision number is already taken, find next available
while (existingIds.has(candidateId)) {
Expand Down Expand Up @@ -628,9 +643,11 @@ module ProcessOut {
const stateManager = StateManager.getInstance();

// Generate component ID - use content-based if signature provided, fallback to call order
const componentId = signature
? generateContentBasedComponentId(signature)
: generateAutoComponentId();
let componentId = generateAutoComponentId();

if (signature) {
componentId = generateContentBasedComponentId(signature);
}

// Get current view from context
const currentView = getCurrentViewContext().currentView;
Expand Down
Loading