Skip to content

Commit

Permalink
task: add support for buyer option (#212)
Browse files Browse the repository at this point in the history
* Allow passing a buyer object of billing / shipping details

* Add buyer option validation

* Uncomment test case

* Remove unnecessary buyer argument from validateBuyer

* Allow values with same type from the schema or null

* Validate shippingDetails and empty objects

* Add buyer displayName and externalIdentifier optional properties

* Allow null values for displayName and externalIdentifier
  • Loading branch information
luca-gr4vy authored Aug 13, 2024
1 parent 03bb7b7 commit 2043ca5
Show file tree
Hide file tree
Showing 5 changed files with 258 additions and 5 deletions.
1 change: 1 addition & 0 deletions packages/embed/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ export const optionKeys = [
'externalIdentifier',
'buyerId',
'buyerExternalIdentifier',
'buyer',
'environment',
'store',
'country',
Expand Down
5 changes: 1 addition & 4 deletions packages/embed/src/theme/create-theme.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,4 @@
type DeepPartial<T> = {
// eslint-disable-next-line
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}
import { DeepPartial } from 'types'

export type ThemeOptions = {
borderWidths: Record<string, string>
Expand Down
37 changes: 37 additions & 0 deletions packages/embed/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
export type DeepPartial<T> = {
// eslint-disable-next-line
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P]
}

export type Config = {
element: HTMLElement // The element to insert the integration at
form?: Element // The form to bind the integration to
Expand All @@ -10,6 +15,7 @@ export type Config = {
externalIdentifier?: string // an optional external identifier
buyerId?: string // the ID of the buyer to associate the payment methods to
buyerExternalIdentifier?: string // the external ID of the buyer to associate the payment methods to
buyer?: Buyer
environment?: 'production' | 'sandbox'
store?: 'ask' | boolean
country: string
Expand Down Expand Up @@ -56,6 +62,28 @@ export type Config = {
optionLabels?: Record<string, string>
}

export type BillingDetails = {
firstName: string
lastName: string
emailAddress: string
phoneNumber: string
address: {
houseNumberOrName: string
line1: string
line2: string
organization: string
city: string
postalCode: string
country: string
state?: string
stateCode?: string
}
taxId: {
value: string
kind: string
}
}

export type BillingAddressFields = {
address?: {
houseNumberOrName?: boolean
Expand All @@ -71,6 +99,15 @@ export type BillingAddressFields = {
taxId?: boolean
}

export type ShippingDetails = BillingDetails

export type Buyer = {
displayName?: string | null
externalIdentifier?: string | null
billingDetails?: DeepPartial<BillingDetails>
shippingDetails?: DeepPartial<ShippingDetails>
}

export type CustomOption = {
method: string
label: string
Expand Down
94 changes: 94 additions & 0 deletions packages/embed/src/validation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,100 @@ describe('validate', () => {
buyerId: '123',
})
).toBeTruthy()
expect(
validate({
...options,
shippingDetailsId: '123',
buyer: {
billingDetails: {
firstName: 'John',
},
},
})
).toBeFalsy()
})

test('should validate buyer details', () => {
jest.spyOn(document, 'querySelector').mockImplementation(() => {
return document.createElement('div')
})

const options = {
element: `#app`,
form: null,
amount: 1299,
currency: `USD`,
iframeHost: `127.0.0.1:8080`,
apiHost: `127.0.0.1:3100`,
token: `123456`,
country: 'US',
}

expect(validate(options)).toBeTruthy()
expect(
validate({ ...options, buyerExternalIdentifier: '123' })
).toBeTruthy()
expect(validate({ ...options, buyerId: '123' })).toBeTruthy()
expect(
validate({
...options,
buyer: {
displayName: 'Test buyer',
externalIdentifier: '123',
billingDetails: {
firstName: 'John',
lastName: null,
},
shippingDetails: {
address: {
country: 'GB',
},
},
},
})
).toBeTruthy()
expect(
validate({
...options,
buyer: {
billingDetails: {
firstName: 'John',
unknown: 'unknown',
},
unknown: 'unknown',
} as any,
})
).toBeFalsy()
expect(
validate({
...options,
buyer: {
billingDetails: {},
} as any,
})
).toBeFalsy()
expect(
validate({
...options,
buyerExternalIdentifier: '123',
buyer: {
billingDetails: {
firstName: 'John',
},
},
})
).toBeFalsy()
expect(
validate({
...options,
buyerId: '123',
buyer: {
billingDetails: {
firstName: 'John',
},
},
})
).toBeFalsy()
})
})

Expand Down
126 changes: 125 additions & 1 deletion packages/embed/src/validation.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SetupConfig } from './types'
import { SetupConfig, Buyer } from './types'

// checks if a value needs validation to pass
export const canSkipValidation = ({
Expand All @@ -11,6 +11,31 @@ export const canSkipValidation = ({
return !required && [undefined, null].includes(value)
}

// checks if object
export const isObject = (object?: any) =>
object && Object.prototype.toString.call(object) === '[object Object]'

// checks if object is empty
export const isEmptyObject = (object?: any) =>
isObject(object) && Object.keys(object).length === 0

// checks if object adheres to another's schema
export const isObjectWithSchema = (object?: any, schemaObject?: any) => {
if (isEmptyObject(object)) {
return false
}
return Object.entries(object).every(([key, val]) => {
if (isObject(val)) {
return isObjectWithSchema(val, schemaObject[key])
}
return (
schemaObject &&
key in schemaObject &&
(typeof schemaObject[key] === typeof val || val === null)
)
})
}

// Validates a HTML element
export const validateHTMLElement = ({
argument,
Expand Down Expand Up @@ -193,6 +218,65 @@ export const validateStore = ({
return false
}

const buyerDetails = {
firstName: '',
lastName: '',
emailAddress: '',
phoneNumber: '',
address: {
houseNumberOrName: '',
line1: '',
line2: '',
organization: '',
city: '',
postalCode: '',
country: '',
state: '',
stateCode: '',
},
taxId: {
value: '',
kind: '',
},
}

const buyerObject: Buyer = {
displayName: '',
externalIdentifier: '',
billingDetails: buyerDetails,
shippingDetails: buyerDetails,
}

export const validateBuyer = ({
argument,
value,
message,
required,
expected,
callback,
}: {
argument: string
value: any
message: string
required?: boolean
expected: typeof buyerObject
callback?: (name: string, event: { message: string }) => void
}) => {
const valid = value && isObjectWithSchema(value, buyerObject)

if (canSkipValidation({ required, value }) || valid) {
return true
}

emitArgumentError({
argument,
message: `${argument} ${message}`,
callback,
_expected: expected,
})
return false
}

// Validates a type
export const validateType = ({
argument,
Expand Down Expand Up @@ -248,15 +332,18 @@ export const emitArgumentError = ({
argument,
message,
callback,
...rest
}: {
argument: string
message: string
callback?: (name: string, event: { message: string }) => void
[key: string]: any
}) => {
const error = {
code: `argumentError`,
argument,
message,
...rest,
}
console.error(`Gr4vy - Error`, error)
callback?.(`argumentError`, error)
Expand Down Expand Up @@ -357,6 +444,14 @@ export const validate = (options: SetupConfig) =>
required: false,
callback: options.onEvent,
}) &&
validateType({
argument: 'buyerExternalIdentifier',
value: options.buyerExternalIdentifier && options.buyer,
type: 'string',
message: 'must be used without a buyer',
required: false,
callback: options.onEvent,
}) &&
validateType({
argument: 'buyerId',
value: options.buyerId,
Expand All @@ -365,6 +460,30 @@ export const validate = (options: SetupConfig) =>
required: false,
callback: options.onEvent,
}) &&
validateType({
argument: 'buyerId',
value: options.buyerId && options.buyer,
type: 'string',
message: 'must be used without a buyer',
required: false,
callback: options.onEvent,
}) &&
validateBuyer({
argument: 'buyer',
value: options.buyer,
message: 'must be a valid object',
required: false,
expected: buyerObject,
callback: options.onEvent,
}) &&
validateType({
argument: 'buyer',
value:
options.buyer && (options.buyerExternalIdentifier || options.buyerId),
type: 'object',
message: 'must be used without buyerExternalIdentifier or buyerId',
required: false,
}) &&
validateType({
argument: 'environment',
value: options.environment,
Expand Down Expand Up @@ -442,6 +561,11 @@ export const validate = (options: SetupConfig) =>
: true,
message: 'must be used with a buyerId or buyerExternalId',
}) &&
validateCondition({
argument: 'shippingDetailsId',
condition: options.shippingDetailsId ? !options.buyer : true,
message: 'must be used without a buyer',
}) &&
validateType({
argument: 'hasBeforeTransaction',
value: options.onBeforeTransaction,
Expand Down

0 comments on commit 2043ca5

Please sign in to comment.