Przeglądaj źródła

fix(payments-plugin): Stripe - send correct amount for JPY

This fixes the amount sent to Stripe when dealing with currencies that do not have a fractional
(minor) unit such as JPY. Closes #1630. Also adds a e2e test suite for the StripePlugin.
Michael Bromley 3 lat temu
rodzic
commit
cd0a48f1d9

+ 1 - 0
e2e-common/tsconfig.e2e.json

@@ -5,6 +5,7 @@
     "lib": ["es2015"],
     "skipLibCheck": true,
     "inlineSourceMap": false,
+    "sourceMap": true,
     "allowSyntheticDefaultImports": true,
     "esModuleInterop": true,
     "allowJs": false,

+ 18 - 0
packages/payments-plugin/e2e/graphql/admin-queries.ts

@@ -1,3 +1,4 @@
+import { CHANNEL_FRAGMENT } from '@vendure/core/e2e/graphql/fragments';
 import gql from 'graphql-tag';
 
 export const PAYMENT_METHOD_FRAGMENT = gql`
@@ -94,3 +95,20 @@ export const GET_ORDER_PAYMENTS = gql`
         }
     }
 `;
+
+export const CREATE_CHANNEL = gql`
+    mutation CreateChannel($input: CreateChannelInput!) {
+        createChannel(input: $input) {
+            ... on Channel {
+                id
+                code
+                token
+                currencyCode
+            }
+            ... on ErrorResult {
+                errorCode
+                message
+            }
+        }
+    }
+`;

Plik diff jest za duży
+ 503 - 489
packages/payments-plugin/e2e/graphql/generated-admin-types.ts


Plik diff jest za duży
+ 714 - 699
packages/payments-plugin/e2e/graphql/generated-shop-types.ts


+ 10 - 0
packages/payments-plugin/e2e/graphql/shop-queries.ts

@@ -12,6 +12,7 @@ export const TEST_ORDER_FRAGMENT = gql`
         shippingWithTax
         total
         totalWithTax
+        currencyCode
         couponCodes
         discounts {
             adjustmentSource
@@ -196,3 +197,12 @@ export const GET_ORDER_BY_CODE = gql`
     }
     ${TEST_ORDER_FRAGMENT}
 `;
+
+export const GET_ACTIVE_ORDER = gql`
+    query GetActiveOrder {
+        activeOrder {
+            ...TestOrderFragment
+        }
+    }
+    ${TEST_ORDER_FRAGMENT}
+`;

+ 249 - 0
packages/payments-plugin/e2e/stripe-payment.e2e-spec.ts

@@ -0,0 +1,249 @@
+/* tslint:disable:no-non-null-assertion */
+import { mergeConfig } from '@vendure/core';
+import { CreateProduct, CreateProductVariants } from '@vendure/core/e2e/graphql/generated-e2e-admin-types';
+import { CREATE_PRODUCT, CREATE_PRODUCT_VARIANTS } from '@vendure/core/e2e/graphql/shared-definitions';
+import { createTestEnvironment, E2E_DEFAULT_CHANNEL_TOKEN } from '@vendure/testing';
+import gql from 'graphql-tag';
+import nock from 'nock';
+import path from 'path';
+
+import { initialData } from '../../../e2e-common/e2e-initial-data';
+import { testConfig, TEST_SETUP_TIMEOUT_MS } from '../../../e2e-common/test-config';
+import { StripePlugin } from '../src/stripe';
+import { stripePaymentMethodHandler } from '../src/stripe/stripe.handler';
+
+import { CREATE_CHANNEL, CREATE_PAYMENT_METHOD, GET_CUSTOMER_LIST } from './graphql/admin-queries';
+import {
+    CreateChannelMutation,
+    CreateChannelMutationVariables,
+    CreatePaymentMethod,
+    CurrencyCode,
+    GetCustomerList,
+    GetCustomerListQuery,
+    LanguageCode,
+} from './graphql/generated-admin-types';
+import {
+    AddItemToOrder,
+    GetActiveOrderQuery,
+    TestOrderFragmentFragment,
+} from './graphql/generated-shop-types';
+import { ADD_ITEM_TO_ORDER, GET_ACTIVE_ORDER } from './graphql/shop-queries';
+import { setShipping } from './payment-helpers';
+
+export const CREATE_STRIPE_PAYMENT_INTENT = gql`
+    mutation createStripePaymentIntent {
+        createStripePaymentIntent
+    }
+`;
+
+describe('Stripe payments', () => {
+    const devConfig = mergeConfig(testConfig(), {
+        plugins: [
+            StripePlugin.init({
+                apiKey: 'test-api-key',
+                webhookSigningSecret: 'test-signing-secret',
+                storeCustomersInStripe: true,
+            }),
+        ],
+    });
+    const { shopClient, adminClient, server } = createTestEnvironment(devConfig);
+    let started = false;
+    let customers: GetCustomerListQuery['customers']['items'];
+    let order: TestOrderFragmentFragment;
+    let serverPort: number;
+    beforeAll(async () => {
+        serverPort = devConfig.apiOptions.port;
+        await server.init({
+            initialData,
+            productsCsvPath: path.join(__dirname, 'fixtures/e2e-products-minimal.csv'),
+            customerCount: 2,
+        });
+        started = true;
+        await adminClient.asSuperAdmin();
+        ({
+            customers: { items: customers },
+        } = await adminClient.query<GetCustomerList.Query, GetCustomerList.Variables>(GET_CUSTOMER_LIST, {
+            options: {
+                take: 2,
+            },
+        }));
+    }, TEST_SETUP_TIMEOUT_MS);
+
+    afterAll(async () => {
+        await server.destroy();
+    });
+
+    it('Should start successfully', async () => {
+        expect(started).toEqual(true);
+        expect(customers).toHaveLength(2);
+    });
+
+    it('Should prepare an order', async () => {
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        const { addItemToOrder } = await shopClient.query<AddItemToOrder.Mutation, AddItemToOrder.Variables>(
+            ADD_ITEM_TO_ORDER,
+            {
+                productVariantId: 'T_1',
+                quantity: 2,
+            },
+        );
+        order = addItemToOrder as TestOrderFragmentFragment;
+        expect(order.code).toBeDefined();
+    });
+
+    it('Should add a Stripe paymentMethod', async () => {
+        const { createPaymentMethod } = await adminClient.query<
+            CreatePaymentMethod.Mutation,
+            CreatePaymentMethod.Variables
+        >(CREATE_PAYMENT_METHOD, {
+            input: {
+                code: `stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`,
+                name: 'Stripe payment test',
+                description: 'This is a Stripe test payment method',
+                enabled: true,
+                handler: {
+                    code: stripePaymentMethodHandler.code,
+                    arguments: [],
+                },
+            },
+        });
+        expect(createPaymentMethod.code).toBe(`stripe-payment-${E2E_DEFAULT_CHANNEL_TOKEN}`);
+
+        await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+        await setShipping(shopClient);
+    });
+
+    it('if no customer id exists, makes a call to create', async () => {
+        let createCustomerPayload: { name: string; email: string } | undefined;
+        const emptyList = { data: [] };
+        nock('https://api.stripe.com/')
+            .get(/\/v1\/customers.*/)
+            .reply(200, emptyList);
+        nock('https://api.stripe.com/')
+            .post('/v1/customers', body => {
+                createCustomerPayload = body;
+                return true;
+            })
+            .reply(201, {
+                id: 'new-customer-id',
+            });
+        nock('https://api.stripe.com/').post('/v1/payment_intents').reply(200, {
+            client_secret: 'test-client-secret',
+        });
+
+        const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+        expect(createCustomerPayload).toEqual({
+            email: 'hayden.zieme12@hotmail.com',
+            name: 'Hayden Zieme',
+        });
+    });
+
+    it('should send correct payload to create payment intent', async () => {
+        let createPaymentIntentPayload: any;
+        const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+        nock('https://api.stripe.com/')
+            .post('/v1/payment_intents', body => {
+                createPaymentIntentPayload = body;
+                return true;
+            })
+            .reply(200, {
+                client_secret: 'test-client-secret',
+            });
+        const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+        expect(createPaymentIntentPayload).toEqual({
+            amount: activeOrder?.totalWithTax.toString(),
+            currency: activeOrder?.currencyCode?.toLowerCase(),
+            customer: 'new-customer-id',
+            'automatic_payment_methods[enabled]': 'true',
+            'metadata[channelToken]': E2E_DEFAULT_CHANNEL_TOKEN,
+            'metadata[orderId]': '1',
+            'metadata[orderCode]': activeOrder?.code,
+        });
+        expect(createStripePaymentIntent).toEqual('test-client-secret');
+    });
+
+    // https://github.com/vendure-ecommerce/vendure/issues/1630
+    describe('currencies with no fractional units', () => {
+        let japanProductId: string;
+        beforeAll(async () => {
+            const JAPAN_CHANNEL_TOKEN = 'japan-channel-token';
+            const { createChannel } = await adminClient.query<
+                CreateChannelMutation,
+                CreateChannelMutationVariables
+            >(CREATE_CHANNEL, {
+                input: {
+                    code: 'japan-channel',
+                    currencyCode: CurrencyCode.JPY,
+                    token: JAPAN_CHANNEL_TOKEN,
+                    defaultLanguageCode: LanguageCode.en,
+                    defaultShippingZoneId: 'T_1',
+                    defaultTaxZoneId: 'T_1',
+                    pricesIncludeTax: true,
+                },
+            });
+
+            adminClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
+            shopClient.setChannelToken(JAPAN_CHANNEL_TOKEN);
+
+            const { createProduct } = await adminClient.query<
+                CreateProduct.Mutation,
+                CreateProduct.Variables
+            >(CREATE_PRODUCT, {
+                input: {
+                    translations: [
+                        {
+                            languageCode: LanguageCode.en,
+                            name: 'Channel Product',
+                            slug: 'channel-product',
+                            description: 'Channel product',
+                        },
+                    ],
+                },
+            });
+            const { createProductVariants } = await adminClient.query<
+                CreateProductVariants.Mutation,
+                CreateProductVariants.Variables
+            >(CREATE_PRODUCT_VARIANTS, {
+                input: [
+                    {
+                        productId: createProduct.id,
+                        sku: 'PV1',
+                        optionIds: [],
+                        price: 5000,
+                        stockOnHand: 100,
+                        translations: [{ languageCode: LanguageCode.en, name: 'Variant 1' }],
+                    },
+                ],
+            });
+            japanProductId = createProductVariants[0]!.id;
+        });
+
+        it('prepares order', async () => {
+            await shopClient.asUserWithCredentials(customers[0].emailAddress, 'test');
+            const { addItemToOrder } = await shopClient.query<
+                AddItemToOrder.Mutation,
+                AddItemToOrder.Variables
+            >(ADD_ITEM_TO_ORDER, {
+                productVariantId: japanProductId,
+                quantity: 1,
+            });
+            expect((addItemToOrder as any).totalWithTax).toBe(5000);
+        });
+
+        it('sends correct amount when creating payment intent', async () => {
+            let createPaymentIntentPayload: any;
+            const { activeOrder } = await shopClient.query<GetActiveOrderQuery>(GET_ACTIVE_ORDER);
+            nock('https://api.stripe.com/')
+                .post('/v1/payment_intents', body => {
+                    createPaymentIntentPayload = body;
+                    return true;
+                })
+                .reply(200, {
+                    client_secret: 'test-client-secret',
+                });
+            const { createStripePaymentIntent } = await shopClient.query(CREATE_STRIPE_PAYMENT_INTENT);
+            expect(createPaymentIntentPayload.amount).toBe((activeOrder!.totalWithTax / 100).toString());
+            expect(createPaymentIntentPayload.currency).toBe('jpy');
+        });
+    });
+});

+ 32 - 3
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -1,5 +1,12 @@
 import { Inject, Injectable } from '@nestjs/common';
-import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
+import {
+    CurrencyCode,
+    Customer,
+    Logger,
+    Order,
+    RequestContext,
+    TransactionalConnection,
+} from '@vendure/core';
 import Stripe from 'stripe';
 
 import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
@@ -7,7 +14,7 @@ import { StripePluginOptions } from './types';
 
 @Injectable()
 export class StripeService {
-    private stripe: Stripe;
+    protected stripe: Stripe;
 
     constructor(
         private connection: TransactionalConnection,
@@ -25,8 +32,20 @@ export class StripeService {
             customerId = await this.getStripeCustomerId(ctx, order);
         }
 
+        // From the [Stripe docs](https://stripe.com/docs/currencies#zero-decimal):
+        // > All API requests expect amounts to be provided in a currency’s smallest unit.
+        // > For example, to charge 10 USD, provide an amount value of 1000 (that is, 1000 cents).
+        // > For zero-decimal currencies, still provide amounts as an integer but without multiplying by 100.
+        // > For example, to charge ¥500, provide an amount value of 500.
+        //
+        // Therefore, for a fractionless currency like JPY, we need to divide the amount by 100 (since Vendure always
+        // stores money amounts multiplied by 100). See https://github.com/vendure-ecommerce/vendure/issues/1630
+        const amountInMinorUnits = this.currencyHasFractionPart(order.currencyCode)
+            ? order.totalWithTax
+            : Math.round(order.totalWithTax / 100);
+
         const { client_secret } = await this.stripe.paymentIntents.create({
-            amount: order.totalWithTax,
+            amount: amountInMinorUnits,
             currency: order.currencyCode.toLowerCase(),
             customer: customerId,
             automatic_payment_methods: {
@@ -112,4 +131,14 @@ export class StripeService {
 
         return stripeCustomerId;
     }
+
+    private currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
+        const parts = new Intl.NumberFormat(undefined, {
+            style: 'currency',
+            currency: currencyCode,
+            currencyDisplay: 'symbol',
+        }).formatToParts(123.45);
+        const hasFractionPart = !!parts.find(p => p.type === 'fraction');
+        return hasFractionPart;
+    }
 }

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików