Sfoglia il codice sorgente

Merge branch 'master' into minor

Michael Bromley 2 anni fa
parent
commit
efbf3351cc

+ 12 - 0
CHANGELOG.md

@@ -1,3 +1,15 @@
+## <small>2.0.8 (2023-09-27)</small>
+
+
+#### Fixes
+
+* **admin-ui** Fix creating nullable string fields ([7e2c17a](https://github.com/vendure-ecommerce/vendure/commit/7e2c17a)), closes [#2343](https://github.com/vendure-ecommerce/vendure/issues/2343)
+* **admin-ui** Fix link to Asset detail from asset picker ([4539de3](https://github.com/vendure-ecommerce/vendure/commit/4539de3)), closes [#2411](https://github.com/vendure-ecommerce/vendure/issues/2411)
+* **core** Implement Refund lines fields resolver ([6b4da6c](https://github.com/vendure-ecommerce/vendure/commit/6b4da6c)), closes [#2406](https://github.com/vendure-ecommerce/vendure/issues/2406)
+* **core** Prevent negative total from compounded promotions ([0740c87](https://github.com/vendure-ecommerce/vendure/commit/0740c87)), closes [#2385](https://github.com/vendure-ecommerce/vendure/issues/2385)
+* **payments-plugin** Fix stripe payment transaction handling (#2402) ([fd8a777](https://github.com/vendure-ecommerce/vendure/commit/fd8a777)), closes [#2402](https://github.com/vendure-ecommerce/vendure/issues/2402)
+* **admin-ui** Add image carousel to asset preview dialog (#2370) ([bd834d0](https://github.com/vendure-ecommerce/vendure/commit/bd834d0)), closes [#2370](https://github.com/vendure-ecommerce/vendure/issues/2370) [#2129](https://github.com/vendure-ecommerce/vendure/issues/2129)
+
 ## <small>2.0.7 (2023-09-08)</small>
 ## <small>2.0.7 (2023-09-08)</small>
 
 
 
 

+ 1 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.html

@@ -46,7 +46,7 @@
                 >
                 >
             </ng-container>
             </ng-container>
             <div *ngIf="selectionManager.selection.length === 1">
             <div *ngIf="selectionManager.selection.length === 1">
-                <a [routerLink]="['./', lastSelected().id]" class="button-ghost">
+                <a [routerLink]="['/catalog/assets/', lastSelected().id]" (click)="editAssetClick.emit()" class="button-ghost">
                     <clr-icon shape="pencil"></clr-icon> {{ 'common.edit' | translate }}
                     <clr-icon shape="pencil"></clr-icon> {{ 'common.edit' | translate }}
                     <clr-icon shape="arrow right"></clr-icon>
                     <clr-icon shape="arrow right"></clr-icon>
                 </a>
                 </a>

+ 10 - 1
packages/admin-ui/src/lib/core/src/shared/components/asset-gallery/asset-gallery.component.ts

@@ -1,4 +1,12 @@
-import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges } from '@angular/core';
+import {
+    ChangeDetectionStrategy,
+    Component,
+    EventEmitter,
+    Input,
+    OnChanges,
+    Output,
+    SimpleChanges,
+} from '@angular/core';
 
 
 import { SelectionManager } from '../../../common/utilities/selection-manager';
 import { SelectionManager } from '../../../common/utilities/selection-manager';
 import { ModalService } from '../../../providers/modal/modal.service';
 import { ModalService } from '../../../providers/modal/modal.service';
@@ -21,6 +29,7 @@ export class AssetGalleryComponent implements OnChanges {
     @Input() canDelete = false;
     @Input() canDelete = false;
     @Output() selectionChange = new EventEmitter<AssetLike[]>();
     @Output() selectionChange = new EventEmitter<AssetLike[]>();
     @Output() deleteAssets = new EventEmitter<AssetLike[]>();
     @Output() deleteAssets = new EventEmitter<AssetLike[]>();
+    @Output() editAssetClick = new EventEmitter<void>();
 
 
     selectionManager = new SelectionManager<AssetLike>({
     selectionManager = new SelectionManager<AssetLike>({
         multiSelect: this.multiSelect,
         multiSelect: this.multiSelect,

+ 1 - 0
packages/admin-ui/src/lib/core/src/shared/components/asset-picker-dialog/asset-picker-dialog.component.html

@@ -21,6 +21,7 @@
     [assets]="(assets$ | async)! | paginate: paginationConfig"
     [assets]="(assets$ | async)! | paginate: paginationConfig"
     [multiSelect]="multiSelect"
     [multiSelect]="multiSelect"
     (selectionChange)="selection = $event"
     (selectionChange)="selection = $event"
+    (editAssetClick)="cancel()"
     #assetGalleryComponent
     #assetGalleryComponent
 ></vdr-asset-gallery>
 ></vdr-asset-gallery>
 
 

+ 75 - 0
packages/core/e2e/order-promotion.e2e-spec.ts

@@ -1942,6 +1942,81 @@ describe('Promotions applied to Orders', () => {
         expect(applyCouponCode.totalWithTax).toBe(96);
         expect(applyCouponCode.totalWithTax).toBe(96);
     });
     });
 
 
+    // https://github.com/vendure-ecommerce/vendure/issues/2385
+    it('prevents negative line price', async () => {
+        await shopClient.asAnonymousUser();
+        const item1000 = getVariantBySlug('item-1000')!;
+        const couponCode1 = '100%_off';
+        const couponCode2 = '100%_off';
+        await createPromotion({
+            enabled: true,
+            name: '100% discount ',
+            couponCode: couponCode1,
+            conditions: [],
+            actions: [
+                {
+                    code: productsPercentageDiscount.code,
+                    arguments: [
+                        { name: 'discount', value: '100' },
+                        {
+                            name: 'productVariantIds',
+                            value: `["${item1000.id}"]`,
+                        },
+                    ],
+                },
+            ],
+        });
+        await createPromotion({
+            enabled: true,
+            name: '20% discount ',
+            couponCode: couponCode2,
+            conditions: [],
+            actions: [
+                {
+                    code: productsPercentageDiscount.code,
+                    arguments: [
+                        { name: 'discount', value: '20' },
+                        {
+                            name: 'productVariantIds',
+                            value: `["${item1000.id}"]`,
+                        },
+                    ],
+                },
+            ],
+        });
+
+        await shopClient.query<
+            CodegenShop.ApplyCouponCodeMutation,
+            CodegenShop.ApplyCouponCodeMutationVariables
+        >(APPLY_COUPON_CODE, { couponCode: couponCode1 });
+
+        await shopClient.query<
+            CodegenShop.AddItemToOrderMutation,
+            CodegenShop.AddItemToOrderMutationVariables
+        >(ADD_ITEM_TO_ORDER, {
+            productVariantId: item1000.id,
+            quantity: 1,
+        });
+
+        const { activeOrder: check1 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
+            GET_ACTIVE_ORDER,
+        );
+
+        expect(check1!.lines[0].discountedUnitPriceWithTax).toBe(0);
+        expect(check1!.totalWithTax).toBe(0);
+
+        await shopClient.query<
+            CodegenShop.ApplyCouponCodeMutation,
+            CodegenShop.ApplyCouponCodeMutationVariables
+        >(APPLY_COUPON_CODE, { couponCode: couponCode2 });
+
+        const { activeOrder: check2 } = await shopClient.query<CodegenShop.GetActiveOrderQuery>(
+            GET_ACTIVE_ORDER,
+        );
+        expect(check2!.lines[0].discountedUnitPriceWithTax).toBe(0);
+        expect(check2!.totalWithTax).toBe(0);
+    });
+
     async function getProducts() {
     async function getProducts() {
         const result = await adminClient.query<Codegen.GetProductsWithVariantPricesQuery>(
         const result = await adminClient.query<Codegen.GetProductsWithVariantPricesQuery>(
             GET_PRODUCTS_WITH_VARIANT_PRICES,
             GET_PRODUCTS_WITH_VARIANT_PRICES,

+ 15 - 3
packages/core/src/api/resolvers/entity/refund-entity.resolver.ts

@@ -1,9 +1,21 @@
-import { Resolver } from '@nestjs/graphql';
+import { Parent, ResolveField, Resolver } from '@nestjs/graphql';
 
 
+import { idsAreEqual } from '../../../common/index';
 import { Refund } from '../../../entity/refund/refund.entity';
 import { Refund } from '../../../entity/refund/refund.entity';
-import { OrderService } from '../../../service/services/order.service';
+import { PaymentService } from '../../../service/index';
+import { RequestContext } from '../../common/request-context';
+import { Ctx } from '../../decorators/request-context.decorator';
 
 
 @Resolver('Refund')
 @Resolver('Refund')
 export class RefundEntityResolver {
 export class RefundEntityResolver {
-    constructor(private orderService: OrderService) {}
+    constructor(private paymentService: PaymentService) {}
+
+    @ResolveField()
+    async lines(@Ctx() ctx: RequestContext, @Parent() refund: Refund) {
+        if (refund.lines) {
+            return refund.lines;
+        }
+        const payment = await this.paymentService.findOneOrThrow(ctx, refund.paymentId, ['refunds.lines']);
+        return payment.refunds.find(r => idsAreEqual(r.id, refund.id))?.lines ?? [];
+    }
 }
 }

+ 10 - 1
packages/core/src/entity/order-line/order-line.entity.ts

@@ -330,7 +330,16 @@ export class OrderLine extends VendureEntity implements HasCustomFields {
     }
     }
 
 
     addAdjustment(adjustment: Adjustment) {
     addAdjustment(adjustment: Adjustment) {
-        this.adjustments = this.adjustments.concat(adjustment);
+        // We should not allow adding adjustments which would
+        // result in a negative unit price
+        const maxDiscount = this.proratedLinePrice * -1;
+        const limitedAdjustment: Adjustment = {
+            ...adjustment,
+            amount: Math.max(maxDiscount, adjustment.amount),
+        };
+        if (limitedAdjustment.amount !== 0) {
+            this.adjustments = this.adjustments.concat(limitedAdjustment);
+        }
     }
     }
 
 
     /**
     /**

+ 1 - 2
packages/payments-plugin/src/stripe/index.ts

@@ -1,2 +1 @@
-export * from './stripe.plugin';
-export * from './';
+export { StripePlugin } from './stripe.plugin';

+ 1 - 1
packages/payments-plugin/src/stripe/stripe-client.ts

@@ -6,7 +6,7 @@ import Stripe from 'stripe';
 export class VendureStripeClient extends Stripe {
 export class VendureStripeClient extends Stripe {
     constructor(private apiKey: string, public webhookSecret: string) {
     constructor(private apiKey: string, public webhookSecret: string) {
         super(apiKey, {
         super(apiKey, {
-            apiVersion: null as any, // Use accounts default version
+            apiVersion: null as unknown as Stripe.LatestApiVersion, // Use accounts default version
         });
         });
     }
     }
 }
 }

+ 4 - 8
packages/payments-plugin/src/stripe/stripe-utils.ts

@@ -12,10 +12,9 @@ import { CurrencyCode, Order } from '@vendure/core';
  * stores money amounts multiplied by 100). See https://github.com/vendure-ecommerce/vendure/issues/1630
  * stores money amounts multiplied by 100). See https://github.com/vendure-ecommerce/vendure/issues/1630
  */
  */
 export function getAmountInStripeMinorUnits(order: Order): number {
 export function getAmountInStripeMinorUnits(order: Order): number {
-    const amountInStripeMinorUnits = currencyHasFractionPart(order.currencyCode)
+    return currencyHasFractionPart(order.currencyCode)
         ? order.totalWithTax
         ? order.totalWithTax
         : Math.round(order.totalWithTax / 100);
         : Math.round(order.totalWithTax / 100);
-    return amountInStripeMinorUnits;
 }
 }
 
 
 /**
 /**
@@ -24,10 +23,7 @@ export function getAmountInStripeMinorUnits(order: Order): number {
  * used by Vendure.
  * used by Vendure.
  */
  */
 export function getAmountFromStripeMinorUnits(order: Order, stripeAmount: number): number {
 export function getAmountFromStripeMinorUnits(order: Order, stripeAmount: number): number {
-    const amountInVendureMinorUnits = currencyHasFractionPart(order.currencyCode)
-        ? stripeAmount
-        : stripeAmount * 100;
-    return amountInVendureMinorUnits;
+    return currencyHasFractionPart(order.currencyCode) ? stripeAmount : stripeAmount * 100;
 }
 }
 
 
 function currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
 function currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
@@ -36,6 +32,6 @@ function currencyHasFractionPart(currencyCode: CurrencyCode): boolean {
         currency: currencyCode,
         currency: currencyCode,
         currencyDisplay: 'symbol',
         currencyDisplay: 'symbol',
     }).formatToParts(123.45);
     }).formatToParts(123.45);
-    const hasFractionPart = !!parts.find(p => p.type === 'fraction');
-    return hasFractionPart;
+
+    return !!parts.find(p => p.type === 'fraction');
 }
 }

+ 70 - 56
packages/payments-plugin/src/stripe/stripe.controller.ts

@@ -1,18 +1,18 @@
 import { Controller, Headers, HttpStatus, Post, Req, Res } from '@nestjs/common';
 import { Controller, Headers, HttpStatus, Post, Req, Res } from '@nestjs/common';
+import type { PaymentMethod, RequestContext } from '@vendure/core';
 import {
 import {
     InternalServerError,
     InternalServerError,
     LanguageCode,
     LanguageCode,
     Logger,
     Logger,
     Order,
     Order,
     OrderService,
     OrderService,
-    PaymentMethod,
     PaymentMethodService,
     PaymentMethodService,
-    RequestContext,
     RequestContextService,
     RequestContextService,
+    TransactionalConnection,
 } from '@vendure/core';
 } from '@vendure/core';
 import { OrderStateTransitionError } from '@vendure/core/dist/common/error/generated-graphql-shop-errors';
 import { OrderStateTransitionError } from '@vendure/core/dist/common/error/generated-graphql-shop-errors';
-import { Response } from 'express';
-import Stripe from 'stripe';
+import type { Response } from 'express';
+import type Stripe from 'stripe';
 
 
 import { loggerCtx } from './constants';
 import { loggerCtx } from './constants';
 import { stripePaymentMethodHandler } from './stripe.handler';
 import { stripePaymentMethodHandler } from './stripe.handler';
@@ -30,6 +30,7 @@ export class StripeController {
         private orderService: OrderService,
         private orderService: OrderService,
         private stripeService: StripeService,
         private stripeService: StripeService,
         private requestContextService: RequestContextService,
         private requestContextService: RequestContextService,
+        private connection: TransactionalConnection,
     ) {}
     ) {}
 
 
     @Post('stripe')
     @Post('stripe')
@@ -43,72 +44,84 @@ export class StripeController {
             response.status(HttpStatus.BAD_REQUEST).send(missingHeaderErrorMessage);
             response.status(HttpStatus.BAD_REQUEST).send(missingHeaderErrorMessage);
             return;
             return;
         }
         }
+
         const event = request.body as Stripe.Event;
         const event = request.body as Stripe.Event;
         const paymentIntent = event.data.object as Stripe.PaymentIntent;
         const paymentIntent = event.data.object as Stripe.PaymentIntent;
+
         if (!paymentIntent) {
         if (!paymentIntent) {
             Logger.error(noPaymentIntentErrorMessage, loggerCtx);
             Logger.error(noPaymentIntentErrorMessage, loggerCtx);
             response.status(HttpStatus.BAD_REQUEST).send(noPaymentIntentErrorMessage);
             response.status(HttpStatus.BAD_REQUEST).send(noPaymentIntentErrorMessage);
             return;
             return;
         }
         }
+
         const { metadata: { channelToken, orderCode, orderId } = {} } = paymentIntent;
         const { metadata: { channelToken, orderCode, orderId } = {} } = paymentIntent;
-        const ctx = await this.createContext(channelToken, request);
-        const order = await this.orderService.findOneByCode(ctx, orderCode);
-        if (!order) {
-            throw Error(`Unable to find order ${orderCode}, unable to settle payment ${paymentIntent.id}!`);
-        }
-        try {
-            // Throws an error if the signature is invalid
-            await this.stripeService.constructEventFromPayload(ctx, order, request.rawBody, signature);
-        } catch (e: any) {
-            Logger.error(`${signatureErrorMessage} ${signature}: ${(e as Error)?.message}`, loggerCtx);
-            response.status(HttpStatus.BAD_REQUEST).send(signatureErrorMessage);
-            return;
-        }
-        if (event.type === 'payment_intent.payment_failed') {
-            const message = paymentIntent.last_payment_error?.message ?? 'unknown error';
-            Logger.warn(`Payment for order ${orderCode} failed: ${message}`, loggerCtx);
-            response.status(HttpStatus.OK).send('Ok');
-            return;
-        }
-        if (event.type !== 'payment_intent.succeeded') {
-            // This should never happen as the webhook is configured to receive
-            // payment_intent.succeeded and payment_intent.payment_failed events only
-            Logger.info(`Received ${event.type} status update for order ${orderCode}`, loggerCtx);
-            return;
-        }
-        if (order.state !== 'ArrangingPayment') {
-            const transitionToStateResult = await this.orderService.transitionToState(
-                ctx,
-                orderId,
-                'ArrangingPayment',
-            );
-
-            if (transitionToStateResult instanceof OrderStateTransitionError) {
-                Logger.error(
-                    `Error transitioning order ${orderCode} to ArrangingPayment state: ${transitionToStateResult.message}`,
-                    loggerCtx,
+        const outerCtx = await this.createContext(channelToken, request);
+
+        await this.connection.withTransaction(outerCtx, async ctx => {
+            const order = await this.orderService.findOneByCode(ctx, orderCode);
+
+            if (!order) {
+                throw new Error(
+                    `Unable to find order ${orderCode}, unable to settle payment ${paymentIntent.id}!`,
                 );
                 );
+            }
+
+            try {
+                // Throws an error if the signature is invalid
+                await this.stripeService.constructEventFromPayload(ctx, order, request.rawBody, signature);
+            } catch (e: any) {
+                Logger.error(`${signatureErrorMessage} ${signature}: ${(e as Error)?.message}`, loggerCtx);
+                response.status(HttpStatus.BAD_REQUEST).send(signatureErrorMessage);
+                return;
+            }
+
+            if (event.type === 'payment_intent.payment_failed') {
+                const message = paymentIntent.last_payment_error?.message ?? 'unknown error';
+                Logger.warn(`Payment for order ${orderCode} failed: ${message}`, loggerCtx);
+                response.status(HttpStatus.OK).send('Ok');
                 return;
                 return;
             }
             }
-        }
 
 
-        const paymentMethod = await this.getPaymentMethod(ctx);
+            if (event.type !== 'payment_intent.succeeded') {
+                // This should never happen as the webhook is configured to receive
+                // payment_intent.succeeded and payment_intent.payment_failed events only
+                Logger.info(`Received ${event.type} status update for order ${orderCode}`, loggerCtx);
+                return;
+            }
 
 
-        const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, orderId, {
-            method: paymentMethod.code,
-            metadata: {
-                paymentIntentAmountReceived: paymentIntent.amount_received,
-                paymentIntentId: paymentIntent.id,
-            },
-        });
+            if (order.state !== 'ArrangingPayment') {
+                const transitionToStateResult = await this.orderService.transitionToState(
+                    ctx,
+                    orderId,
+                    'ArrangingPayment',
+                );
 
 
-        if (!(addPaymentToOrderResult instanceof Order)) {
-            Logger.error(
-                `Error adding payment to order ${orderCode}: ${addPaymentToOrderResult.message}`,
-                loggerCtx,
-            );
-            return;
-        }
+                if (transitionToStateResult instanceof OrderStateTransitionError) {
+                    Logger.error(
+                        `Error transitioning order ${orderCode} to ArrangingPayment state: ${transitionToStateResult.message}`,
+                        loggerCtx,
+                    );
+                    return;
+                }
+            }
+
+            const paymentMethod = await this.getPaymentMethod(ctx);
+
+            const addPaymentToOrderResult = await this.orderService.addPaymentToOrder(ctx, orderId, {
+                method: paymentMethod.code,
+                metadata: {
+                    paymentIntentAmountReceived: paymentIntent.amount_received,
+                    paymentIntentId: paymentIntent.id,
+                },
+            });
+
+            if (!(addPaymentToOrderResult instanceof Order)) {
+                Logger.error(
+                    `Error adding payment to order ${orderCode}: ${addPaymentToOrderResult.message}`,
+                    loggerCtx,
+                );
+            }
+        });
 
 
         Logger.info(`Stripe payment intent id ${paymentIntent.id} added to order ${orderCode}`, loggerCtx);
         Logger.info(`Stripe payment intent id ${paymentIntent.id} added to order ${orderCode}`, loggerCtx);
         response.status(HttpStatus.OK).send('Ok');
         response.status(HttpStatus.OK).send('Ok');
@@ -127,6 +140,7 @@ export class StripeController {
         const method = (await this.paymentMethodService.findAll(ctx)).items.find(
         const method = (await this.paymentMethodService.findAll(ctx)).items.find(
             m => m.handler.code === stripePaymentMethodHandler.code,
             m => m.handler.code === stripePaymentMethodHandler.code,
         );
         );
+
         if (!method) {
         if (!method) {
             throw new InternalServerError(`[${loggerCtx}] Could not find Stripe PaymentMethod`);
             throw new InternalServerError(`[${loggerCtx}] Could not find Stripe PaymentMethod`);
         }
         }

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

@@ -2,7 +2,6 @@ import { Inject, Injectable } from '@nestjs/common';
 import { ModuleRef } from '@nestjs/core';
 import { ModuleRef } from '@nestjs/core';
 import { ConfigArg } from '@vendure/common/lib/generated-types';
 import { ConfigArg } from '@vendure/common/lib/generated-types';
 import {
 import {
-    Ctx,
     Customer,
     Customer,
     Injector,
     Injector,
     Logger,
     Logger,
@@ -25,9 +24,9 @@ import { StripePluginOptions } from './types';
 @Injectable()
 @Injectable()
 export class StripeService {
 export class StripeService {
     constructor(
     constructor(
+        @Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
         private connection: TransactionalConnection,
         private connection: TransactionalConnection,
         private paymentMethodService: PaymentMethodService,
         private paymentMethodService: PaymentMethodService,
-        @Inject(STRIPE_PLUGIN_OPTIONS) private options: StripePluginOptions,
         private moduleRef: ModuleRef,
         private moduleRef: ModuleRef,
     ) {}
     ) {}
 
 

+ 3 - 3
packages/payments-plugin/src/stripe/types.ts

@@ -1,7 +1,7 @@
-import { Injector, Order, RequestContext } from '@vendure/core';
 import '@vendure/core/dist/entity/custom-entity-fields';
 import '@vendure/core/dist/entity/custom-entity-fields';
-import { Request } from 'express';
-import Stripe from 'stripe';
+import type { Injector, Order, RequestContext } from '@vendure/core';
+import type { Request } from 'express';
+import type Stripe from 'stripe';
 
 
 // Note: deep import is necessary here because CustomCustomerFields is also extended in the Braintree
 // Note: deep import is necessary here because CustomCustomerFields is also extended in the Braintree
 // plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617
 // plugin. Reference: https://github.com/microsoft/TypeScript/issues/46617