Browse Source

fix(payments-plugin): Verify Stripe payment intent amount

This prevents a case where a Stripe payment intent is created for $x, and then by some
means (e.g. direct GraphQL API manipulation), the Customer increases the Order value
before the Stripe webhook returns. Previously, the new Order.totalWithTax would be
used to create the resulting Payment. With this change, the actual amount captured
by the Stripe payment intent is used.
Michael Bromley 3 years ago
parent
commit
b72ae18d61

+ 41 - 0
packages/payments-plugin/src/stripe/stripe-utils.ts

@@ -0,0 +1,41 @@
+import { CurrencyCode, Order } from '@vendure/core';
+
+/**
+ * @description
+ * 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
+ */
+export function getAmountInStripeMinorUnits(order: Order): number {
+    const amountInStripeMinorUnits = currencyHasFractionPart(order.currencyCode)
+        ? order.totalWithTax
+        : Math.round(order.totalWithTax / 100);
+    return amountInStripeMinorUnits;
+}
+
+/**
+ * @description
+ * Performs the reverse of `getAmountInStripeMinorUnits` - converting the Stripe minor units into the format
+ * used by Vendure.
+ */
+export function getAmountFromStripeMinorUnits(order: Order, stripeAmount: number): number {
+    const amountInVendureMinorUnits = currencyHasFractionPart(order.currencyCode)
+        ? stripeAmount
+        : stripeAmount * 100;
+    return amountInVendureMinorUnits;
+}
+
+function 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;
+}

+ 1 - 0
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -103,6 +103,7 @@ export class StripeController {
         const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, orderId, {
             method: paymentMethod.code,
             metadata: {
+                paymentIntentAmountReceived: paymentIntent.amount_received,
                 paymentIntentId: paymentIntent.id,
             },
         });

+ 4 - 2
packages/payments-plugin/src/stripe/stripe.handler.ts

@@ -8,6 +8,7 @@ import {
 } from '@vendure/core';
 import Stripe from 'stripe';
 
+import { getAmountFromStripeMinorUnits, getAmountInStripeMinorUnits } from './stripe-utils';
 import { StripeService } from './stripe.service';
 
 const { StripeError } = Stripe.errors;
@@ -28,14 +29,15 @@ export const stripePaymentMethodHandler = new PaymentMethodHandler({
         stripeService = injector.get(StripeService);
     },
 
-    async createPayment(ctx, _, amount, ___, metadata): Promise<CreatePaymentResult> {
+    async createPayment(ctx, order, amount, ___, metadata): Promise<CreatePaymentResult> {
         // Payment is already settled in Stripe by the time the webhook in stripe.controller.ts
         // adds the payment to the order
         if (ctx.apiType !== 'admin') {
             throw Error(`CreatePayment is not allowed for apiType '${ctx.apiType}'`);
         }
+        const amountInMinorUnits = getAmountFromStripeMinorUnits(order, metadata.paymentIntentAmountReceived);
         return {
-            amount,
+            amount: amountInMinorUnits,
             state: 'Settled' as const,
             transactionId: metadata.paymentIntentId,
         };

+ 2 - 18
packages/payments-plugin/src/stripe/stripe.service.ts

@@ -1,15 +1,9 @@
 import { Inject, Injectable } from '@nestjs/common';
-import {
-    CurrencyCode,
-    Customer,
-    Logger,
-    Order,
-    RequestContext,
-    TransactionalConnection,
-} from '@vendure/core';
+import { Customer, Logger, Order, RequestContext, TransactionalConnection } from '@vendure/core';
 import Stripe from 'stripe';
 
 import { loggerCtx, STRIPE_PLUGIN_OPTIONS } from './constants';
+import { getAmountInStripeMinorUnits } from './stripe-utils';
 import { StripePluginOptions } from './types';
 
 @Injectable()
@@ -122,14 +116,4 @@ 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;
-    }
 }