Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(elasticsearch): Extend search config with fields for script evaluation of every hit (#1143) #1144

Closed
wants to merge 7 commits into from
12 changes: 12 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Security Policy

## Supported Versions

| Version | Supported |
| ------- | ------------------ |
| 1.x.x | :white_check_mark: |
| < 1.0 | :x: |

## Reporting a Vulnerability

To report a security vulnarability, email [[email protected]](mailto:[email protected]).
41 changes: 28 additions & 13 deletions docs/content/developer-guide/payment-integrations/index.md
Original file line number Diff line number Diff line change
@@ -1,22 +1,22 @@
---
title: "Payment Integrations"
title: 'Payment Integrations'
showtoc: true
---

# Payment Integrations

Vendure can support many kinds of payment workflows, such as authorizing and capturing payment in a single step upon checkout or authorizing on checkout and then capturing on fulfillment.
Vendure can support many kinds of payment workflows, such as authorizing and capturing payment in a single step upon checkout or authorizing on checkout and then capturing on fulfillment.

{{< alert "primary" >}}
For a complete working example of a real payment integration, see the [real-world-vendure Braintree plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/braintree)
For a complete working example of a real payment integration, see the [real-world-vendure Braintree plugin](https://github.com/vendure-ecommerce/real-world-vendure/tree/master/src/plugins/braintree)
{{< /alert >}}

## Authorization & Settlement

Typically, there are 2 parts to an online payment: **authorization** and **settlement**:

* **Authorization** is the process by which the customer's bank is contacted to check whether the transaction is allowed. At this stage, no funds are removed from the customer's account.
* **Settlement** (also known as "capture") is the process by which the funds are transferred from the customer's account to the merchant.
- **Authorization** is the process by which the customer's bank is contacted to check whether the transaction is allowed. At this stage, no funds are removed from the customer's account.
- **Settlement** (also known as "capture") is the process by which the funds are transferred from the customer's account to the merchant.

Some merchants do both of these steps at once, when the customer checks out of the store. Others do the authorize step at checkout, and only do the settlement at some later point, e.g. upon shipping the goods to the customer.

Expand All @@ -32,7 +32,7 @@ import { sdk } from 'payment-provider-sdk';

/**
* This is a handler which integrates Vendure with an imaginary
* payment provider, who provide a Node SDK which we use to
* payment provider, who provide a Node SDK which we use to
* interact with their APIs.
*/
const myPaymentIntegration = new PaymentMethodHandler({
Expand Down Expand Up @@ -61,7 +61,7 @@ const myPaymentIntegration = new PaymentMethodHandler({
cardInfo: result.cardInfo,
// Any metadata in the `public` field
// will be available in the Shop API,
// All other metadata is private and
// All other metadata is private and
// only available in the Admin API.
public: {
referenceCode: result.publicId,
Expand All @@ -82,11 +82,11 @@ const myPaymentIntegration = new PaymentMethodHandler({
/** This is called when the `settlePayment` mutation is executed */
settlePayment: async (ctx, order, payment, args): Promise<SettlePaymentResult | SettlePaymentErrorResult> => {
try {
const result = await sdk.charges.capture({
const result = await sdk.charges.capture({
apiKey: args.apiKey,
id: payment.transactionId,
});
return { success: true };
return { success: true };
} catch (err) {
return {
success: false,
Expand Down Expand Up @@ -122,14 +122,14 @@ Once the PaymentMethodHandler is defined as above, you can use it to create a ne
1. Once the active Order has been transitioned to the ArrangingPayment state (see the [Order Workflow guide]({{< relref "order-workflow" >}})), one or more Payments are created by executing the [`addPaymentToOrder` mutation]({{< relref "/docs/graphql-api/shop/mutations#addpaymenttoorder" >}}). This mutation has a required `method` input field, which _must_ match the `code` of one of the configured PaymentMethodHandlers. In the case above, this would be set to `"my-payment-method"`.
```GraphQL
mutation {
addPaymentToOrder(input: {
addPaymentToOrder(input: {
method: "my-payment-method",
metadata: { token: "<some token from the payment provider>" }) {
...Order
}
}
```
The `metadata` field is used to store the specific data required by the payment provider. E.g. some providers have a client-side part which begins the transaction and returns a token which must then be verified on the server side.
The `metadata` field is used to store the specific data required by the payment provider. E.g. some providers have a client-side part which begins the transaction and returns a token which must then be verified on the server side.
2. This mutation internally invokes the [PaymentMethodHandler's `createPayment()` function]({{< relref "payment-method-config-options" >}}#createpayment). This function returns a [CreatePaymentResult object]({{< relref "payment-method-types" >}}#payment-method-types) which is used to create a new [Payment]({{< relref "/docs/typescript-api/entities/payment" >}}). If the Payment amount equals the order total, then the Order is transitioned to either the "PaymentAuthorized" or "PaymentSettled" state and the customer checkout flow is complete.

### Single-step
Expand All @@ -152,7 +152,7 @@ Here's an example which adds a new "Validating" state to the Payment state machi

```TypeScript
/**
* Define a new "Validating" Payment state, and set up the
* Define a new "Validating" Payment state, and set up the
* permitted transitions to/from it.
*/
const customPaymentProcess: CustomPaymentProcess<'Validating'> = {
Expand All @@ -168,7 +168,7 @@ const customPaymentProcess: CustomPaymentProcess<'Validating'> = {
};

/**
* Define a new "ValidatingPayment" Order state, and set up the
* Define a new "ValidatingPayment" Order state, and set up the
* permitted transitions to/from it.
*/
const customOrderProcess: CustomOrderProcess<'ValidatingPayment'> = {
Expand Down Expand Up @@ -229,3 +229,18 @@ export const config: VendureConfig = {
},
};
```

### Integration with hosted payment pages

A hosted payment page is a system that works similar to (Stripe checkout)[https://stripe.com/payments/checkout]. The idea behind this flow is that the customer does not enter any credit card data anywhere on the merchant's site which waives the merchant from the responsibility to take care of sensitive data.

The checkout flow works as follows:

1. The user makes a POST to the card processor's URL via a Vendure served page
2. The card processor accepts card information from the user and authorizes a payment
3. The card processor redirects the user back to Vendure via a POST which contains details about the processed payment
4. There is a pre-shared secret between the merchant and processor used to sign cross-site POST requests

When integrating with a system like this, you would need to create a Controller to accept POST redirects from the payment processor (usually a success and a failure URL), as well as serve a POST form on your store frontend.

With a hosted payment form the payment is already authorized by the time the card processor makes the POST request to Vendure, possibly settled even, so the payment handler won't do anything in particular - just return the data it has been passed. The validation of the POST request is done in the controller or service and the payment amount and payment refernce are just passed to the payment handler which passes them on.
6 changes: 3 additions & 3 deletions packages/admin-ui/src/lib/static/i18n-messages/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"customer-groups": "Grupos de clientes",
"customers": "Clientes",
"dashboard": "Panel de control",
"facets": "Facetas",
"facets": "Etiquetas",
"global-settings": "Ajustes globales",
"job-queue": "Cola de tareas",
"manage-variants": "Gestionar variantes",
Expand Down Expand Up @@ -394,7 +394,7 @@
"countries": "Países",
"customer-groups": "Grupos de clientes",
"customers": "Clientes",
"facets": "Facetas",
"facets": "Etiquetas",
"global-settings": "Ajustes globales",
"job-queue": "Cola de trabajos",
"marketing": "Marketing",
Expand Down Expand Up @@ -654,4 +654,4 @@
"job-result": "Resultado",
"job-state": "Estado"
}
}
}
1 change: 1 addition & 0 deletions packages/core/src/api/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export { ApiType } from './common/get-api-type';
export * from './common/request-context';
export * from './common/extract-session-token';
export * from './decorators/allow.decorator';
export * from './decorators/transaction.decorator';
export * from './decorators/api.decorator';
Expand Down
42 changes: 38 additions & 4 deletions packages/elasticsearch-plugin/e2e/elasticsearch-plugin.e2e-spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ import {
UpdateCollection,
UpdateProduct,
UpdateProductVariants,
UpdateTaxRate
UpdateTaxRate,
} from '../../core/e2e/graphql/generated-e2e-admin-types';
import { SearchProductsShop } from '../../core/e2e/graphql/generated-e2e-shop-types';
import {
Expand Down Expand Up @@ -130,6 +130,17 @@ describe('Elasticsearch plugin', () => {
},
},
},
searchConfig: {
scriptFields: {
answerDouble: {
graphQlType: 'Int!',
environment: 'product',
scriptFn: input => ({
script: `doc['answer'].value * 2`,
}),
},
},
},
}),
DefaultJobQueuePlugin,
],
Expand Down Expand Up @@ -277,8 +288,8 @@ describe('Elasticsearch plugin', () => {
},
);
expect(result.search.collections).toEqual([
{collection: {id: 'T_2', name: 'Plants',},count: 3,},
]);
{ collection: { id: 'T_2', name: 'Plants' }, count: 3 },
]);
});

it('returns correct collections when grouped by product', async () => {
Expand All @@ -291,7 +302,7 @@ describe('Elasticsearch plugin', () => {
},
);
expect(result.search.collections).toEqual([
{collection: {id: 'T_2', name: 'Plants',},count: 3,},
{ collection: { id: 'T_2', name: 'Plants' }, count: 3 },
]);
});

Expand Down Expand Up @@ -1242,6 +1253,29 @@ describe('Elasticsearch plugin', () => {
});
});
});

describe('scriptFields', () => {
it('script mapping', async () => {
const query = `{
search(input: { take: 1, groupByProduct: true, sort: { name: ASC } }) {
items {
productVariantName
customScriptFields {
answerDouble
}
}
}
}`;
const { search } = await shopClient.query(gql(query));

expect(search.items[0]).toEqual({
productVariantName: 'Bonsai Tree',
customScriptFields: {
answerDouble: 84,
},
});
});
});
});

export const SEARCH_PRODUCTS = gql`
Expand Down
25 changes: 24 additions & 1 deletion packages/elasticsearch-plugin/src/build-elastic-body.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ describe('buildElasticBody()', () => {

it('facetValueFilters OR', () => {
const result = buildElasticBody(
{ facetValueFilters: [ { or: ['1', '2'] }] },
{ facetValueFilters: [{ or: ['1', '2'] }] },
searchConfig,
CHANNEL_ID,
LanguageCode.en,
Expand Down Expand Up @@ -386,6 +386,29 @@ describe('buildElasticBody()', () => {
});
});

it('scriptFields option', () => {
const config: DeepRequired<SearchConfig> = {
...searchConfig,
...{
scriptFields: {
test: {
graphQlType: 'String',
environment: 'both',
scriptFn: input => ({
script: `doc['property'].dummyScript(${input.term})`,
}),
},
},
},
};
const result = buildElasticBody({ term: 'test' }, config, CHANNEL_ID, LanguageCode.en);
expect(result.script_fields).toEqual({
test: {
script: `doc['property'].dummyScript(test)`,
},
});
});

describe('price ranges', () => {
it('not grouped by product', () => {
const result = buildElasticBody(
Expand Down
40 changes: 39 additions & 1 deletion packages/elasticsearch-plugin/src/build-elastic-body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { LanguageCode, LogicalOperator, PriceRange, SortOrder } from '@vendure/c
import { DeepRequired, ID, UserInputError } from '@vendure/core';

import { SearchConfig } from './options';
import { ElasticSearchInput, SearchRequestBody } from './types';
import { CustomScriptMapping, ElasticSearchInput, SearchRequestBody } from './types';

/**
* Given a SearchInput object, returns the corresponding Elasticsearch body.
Expand Down Expand Up @@ -113,6 +113,11 @@ export function buildElasticBody(
sortArray.push({ [priceField]: { order: sort.price === SortOrder.ASC ? 'asc' : 'desc' } });
}
}
const scriptFields: any | undefined = createScriptFields(
searchConfig.scriptFields,
input,
groupByProduct,
);
return {
query: searchConfig.mapQuery
? searchConfig.mapQuery(query, input, searchConfig, channelId, enabledOnly)
Expand All @@ -121,6 +126,12 @@ export function buildElasticBody(
from: skip || 0,
size: take || 10,
track_total_hits: searchConfig.totalItemsMaxSize,
...(scriptFields !== undefined
? {
_source: true,
script_fields: scriptFields,
}
: undefined),
};
}

Expand All @@ -130,6 +141,33 @@ function ensureBoolFilterExists(query: { bool: { filter?: any } }) {
}
}

function createScriptFields(
scriptFields: { [fieldName: string]: CustomScriptMapping<[ElasticSearchInput]> },
input: ElasticSearchInput,
groupByProduct?: boolean,
): any | undefined {
if (scriptFields) {
const fields = Object.keys(scriptFields);
if (fields.length) {
const result: any = {};
for (const name of fields) {
const scriptField = scriptFields[name];
if (scriptField.environment === 'product' && groupByProduct === true) {
(result as any)[name] = scriptField.scriptFn(input);
}
if (scriptField.environment === 'variant' && groupByProduct === false) {
(result as any)[name] = scriptField.scriptFn(input);
}
if (scriptField.environment === 'both' || scriptField.environment === undefined) {
(result as any)[name] = scriptField.scriptFn(input);
}
}
return result;
}
}
return undefined;
}

function createPriceFilters(range: PriceRange, withTax: boolean, groupByProduct: boolean): any[] {
const withTaxFix = withTax ? 'WithTax' : '';
if (groupByProduct) {
Expand Down
26 changes: 26 additions & 0 deletions packages/elasticsearch-plugin/src/custom-script-fields.resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { Inject } from '@nestjs/common';
import { ResolveField, Resolver } from '@nestjs/graphql';
import { DeepRequired } from '@vendure/common/lib/shared-types';

import { ELASTIC_SEARCH_OPTIONS } from './constants';
import { ElasticsearchOptions } from './options';

/**
* This resolver is only required if scriptFields are defined for both products and product variants.
* This particular configuration will result in a union type for the
* `SearchResult.customScriptFields` GraphQL field.
*/
@Resolver('CustomScriptFields')
export class CustomScriptFieldsResolver {
constructor(@Inject(ELASTIC_SEARCH_OPTIONS) private options: DeepRequired<ElasticsearchOptions>) {}

@ResolveField()
__resolveType(value: any): string {
const productScriptFields = Object.entries(this.options.searchConfig?.scriptFields || {})
.filter(([, scriptField]) => scriptField.environment !== 'variant')
.map(([k]) => k);
return Object.keys(value).every(k => productScriptFields.includes(k))
? 'CustomProductScriptFields'
: 'CustomProductVariantScriptFields';
}
}
Loading