Browse Source

feat(core): Add couponCodes to Order & mutations to add/remove codes

Relates to #174

BREAKING CHANGE: A new `couponCodes` column is added to the Order table, which will require a DB migration.
Michael Bromley 6 years ago
parent
commit
fdacb4bcef

+ 1 - 0
packages/admin-ui/src/app/common/generated-types.ts

@@ -2191,6 +2191,7 @@ export type Order = Node & {
   billingAddress?: Maybe<OrderAddress>,
   lines: Array<OrderLine>,
   adjustments: Array<Adjustment>,
+  couponCodes: Array<Scalars['String']>,
   payments?: Maybe<Array<Payment>>,
   fulfillments?: Maybe<Array<Fulfillment>>,
   subTotalBeforeTax: Scalars['Int'],

+ 13 - 0
packages/common/src/generated-shop-types.ts

@@ -1301,6 +1301,10 @@ export type Mutation = {
      * third argument 'customFields' will be available.
      */
     adjustOrderLine?: Maybe<Order>;
+    /** Applies the given coupon code to the active Order */
+    applyCouponCode?: Maybe<Order>;
+    /** Removes the given coupon code from the active Order */
+    removeCouponCode?: Maybe<Order>;
     transitionOrderToState?: Maybe<Order>;
     setOrderShippingAddress?: Maybe<Order>;
     setOrderShippingMethod?: Maybe<Order>;
@@ -1358,6 +1362,14 @@ export type MutationAdjustOrderLineArgs = {
     quantity?: Maybe<Scalars['Int']>;
 };
 
+export type MutationApplyCouponCodeArgs = {
+    couponCode: Scalars['String'];
+};
+
+export type MutationRemoveCouponCodeArgs = {
+    couponCode: Scalars['String'];
+};
+
 export type MutationTransitionOrderToStateArgs = {
     state: Scalars['String'];
 };
@@ -1468,6 +1480,7 @@ export type Order = Node & {
     billingAddress?: Maybe<OrderAddress>;
     lines: Array<OrderLine>;
     adjustments: Array<Adjustment>;
+    couponCodes: Array<Scalars['String']>;
     payments?: Maybe<Array<Payment>>;
     fulfillments?: Maybe<Array<Fulfillment>>;
     subTotalBeforeTax: Scalars['Int'];

+ 1 - 0
packages/common/src/generated-types.ts

@@ -2168,6 +2168,7 @@ export type Order = Node & {
   billingAddress?: Maybe<OrderAddress>,
   lines: Array<OrderLine>,
   adjustments: Array<Adjustment>,
+  couponCodes: Array<Scalars['String']>,
   payments?: Maybe<Array<Payment>>,
   fulfillments?: Maybe<Array<Fulfillment>>,
   subTotalBeforeTax: Scalars['Int'],

+ 21 - 0
packages/core/src/api/resolvers/shop/shop-order.resolver.ts

@@ -3,6 +3,7 @@ import {
     MutationAddItemToOrderArgs,
     MutationAddPaymentToOrderArgs,
     MutationAdjustOrderLineArgs,
+    MutationApplyCouponCodeArgs,
     MutationRemoveOrderLineArgs,
     MutationSetCustomerForOrderArgs,
     MutationSetOrderShippingAddressArgs,
@@ -225,6 +226,26 @@ export class ShopOrderResolver {
         return this.orderService.removeItemFromOrder(ctx, order.id, args.orderLineId);
     }
 
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    async applyCouponCode(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationApplyCouponCodeArgs,
+    ): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx, true);
+        return this.orderService.applyCouponCode(ctx, order.id, args.couponCode);
+    }
+
+    @Mutation()
+    @Allow(Permission.UpdateOrder, Permission.Owner)
+    async removeCouponCode(
+        @Ctx() ctx: RequestContext,
+        @Args() args: MutationApplyCouponCodeArgs,
+    ): Promise<Order> {
+        const order = await this.getOrderFromContext(ctx, true);
+        return this.orderService.removeCouponCode(ctx, order.id, args.couponCode);
+    }
+
     @Mutation()
     @Allow(Permission.UpdateOrder, Permission.Owner)
     async addPaymentToOrder(@Ctx() ctx: RequestContext, @Args() args: MutationAddPaymentToOrderArgs) {

+ 4 - 0
packages/core/src/api/schema/shop-api/shop.api.graphql

@@ -22,6 +22,10 @@ type Mutation {
     removeOrderLine(orderLineId: ID!): Order
     "Adjusts an OrderLine. If custom fields are defined on the OrderLine entity, a third argument 'customFields' will be available."
     adjustOrderLine(orderLineId: ID!, quantity: Int): Order
+    "Applies the given coupon code to the active Order"
+    applyCouponCode(couponCode: String!): Order
+    "Removes the given coupon code from the active Order"
+    removeCouponCode(couponCode: String!): Order
     transitionOrderToState(state: String!): Order
     setOrderShippingAddress(input: CreateAddressInput!): Order
     setOrderShippingMethod(shippingMethodId: ID!): Order

+ 1 - 0
packages/core/src/api/schema/type/order.type.graphql

@@ -10,6 +10,7 @@ type Order implements Node {
     billingAddress: OrderAddress
     lines: [OrderLine!]!
     adjustments: [Adjustment!]!
+    couponCodes: [String!]!
     payments: [Payment!]
     fulfillments: [Fulfillment!]
     subTotalBeforeTax: Int!

+ 26 - 0
packages/core/src/common/error/errors.ts

@@ -208,3 +208,29 @@ export class OrderItemsLimitError extends I18nError {
         super('error.order-items-limit-exceeded', { maxItems }, 'ORDER_ITEMS_LIMIT_EXCEEDED');
     }
 }
+
+/**
+ * @description
+ * This error is thrown when the coupon code is not associated with any active Promotion.
+ *
+ * @docsCategory errors
+ * @docsPage Error Types
+ */
+export class CouponCodeInvalidError extends I18nError {
+    constructor(couponCode: string) {
+        super('error.coupon-code-not-valid', { couponCode }, 'COUPON_CODE_INVALID');
+    }
+}
+
+/**
+ * @description
+ * This error is thrown when the coupon code is associated with a Promotion that has expired.
+ *
+ * @docsCategory errors
+ * @docsPage Error Types
+ */
+export class CouponCodeExpiredError extends I18nError {
+    constructor(couponCode: string) {
+        super('error.coupon-code-expired', { couponCode }, 'COUPON_CODE_EXPIRED');
+    }
+}

+ 3 - 0
packages/core/src/entity/order/order.entity.ts

@@ -47,6 +47,9 @@ export class Order extends VendureEntity implements HasCustomFields {
     @OneToMany(type => OrderLine, line => line.order)
     lines: OrderLine[];
 
+    @Column('simple-array')
+    couponCodes: string[];
+
     @Column('simple-json') pendingAdjustments: Adjustment[];
 
     @Column('simple-json') shippingAddress: OrderAddress;

+ 3 - 0
packages/core/src/i18n/messages/en.json

@@ -14,6 +14,8 @@
     "cannot-transition-to-payment-without-customer": "Cannot transition Order to the \"ArrangingPayment\" state without Customer details",
     "channel-not-found":  "No channel with the token \"{ token }\" exists",
     "country-code-not-valid":  "The countryCode \"{ countryCode }\" was not recognized",
+    "coupon-code-expired":  "Coupon code \"{ couponCode }\" has expired",
+    "coupon-code-not-valid":  "Coupon code \"{ couponCode }\" is not valid",
     "create-fulfillment-items-already-fulfilled": "One or more OrderItems have already been fulfilled",
     "create-fulfillment-orders-must-be-settled": "One or more OrderItems belong to an Order which is in an invalid state",
     "create-fulfillment-nothing-to-fulfill": "Nothing to fulfill",
@@ -47,6 +49,7 @@
     "product-id-slug-mismatch": "The provided id and slug refer to different Products",
     "product-variant-option-ids-not-compatible": "ProductVariant optionIds must include one optionId from each of the groups: {groupNames}",
     "product-variant-options-combination-already-exists": "A ProductVariant already exists with the options: {optionNames}",
+    "promotion-must-have-conditions-or-coupon-code": "A Promotion must have either at least one condition or a coupon code set",
     "refund-order-item-already-refunded": "Cannot refund an OrderItem which has already been refunded",
     "refund-order-lines-invalid-order-state": "Cannot refund an Order in the \"{ state }\" state",
     "refund-order-lines-nothing-to-refund": "Nothing to refund",

+ 17 - 1
packages/core/src/service/services/order.service.ts

@@ -55,6 +55,7 @@ import { CustomerService } from './customer.service';
 import { HistoryService } from './history.service';
 import { PaymentMethodService } from './payment-method.service';
 import { ProductVariantService } from './product-variant.service';
+import { PromotionService } from './promotion.service';
 import { StockMovementService } from './stock-movement.service';
 
 export class OrderService {
@@ -74,6 +75,7 @@ export class OrderService {
         private stockMovementService: StockMovementService,
         private refundStateMachine: RefundStateMachine,
         private historyService: HistoryService,
+        private promotionService: PromotionService,
     ) {}
 
     findAll(ctx: RequestContext, options?: ListQueryOptions<Order>): Promise<PaginatedList<Order>> {
@@ -191,6 +193,7 @@ export class OrderService {
             code: generatePublicId(),
             state: this.orderStateMachine.getInitialState(),
             lines: [],
+            couponCodes: [],
             shippingAddress: {},
             billingAddress: {},
             pendingAdjustments: [],
@@ -291,6 +294,19 @@ export class OrderService {
         return updatedOrder;
     }
 
+    async applyCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
+        await this.promotionService.validateCouponCode(couponCode);
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        order.couponCodes.push(couponCode);
+        return this.applyPriceAdjustments(ctx, order);
+    }
+
+    async removeCouponCode(ctx: RequestContext, orderId: ID, couponCode: string) {
+        const order = await this.getOrderOrThrow(ctx, orderId);
+        order.couponCodes = order.couponCodes.filter(cc => cc !== couponCode);
+        return this.applyPriceAdjustments(ctx, order);
+    }
+
     getNextOrderStates(order: Order): OrderState[] {
         return this.orderStateMachine.getNextStates(order);
     }
@@ -719,7 +735,7 @@ export class OrderService {
      */
     private async applyPriceAdjustments(ctx: RequestContext, order: Order): Promise<Order> {
         const promotions = await this.connection.getRepository(Promotion).find({
-            where: { enabled: true },
+            where: { enabled: true, deletedAt: null },
             order: { priorityScore: 'ASC' },
         });
         order = await this.orderCalculator.applyPriceAdjustments(ctx, order, promotions);

+ 17 - 0
packages/core/src/service/services/promotion.service.ts

@@ -123,6 +123,23 @@ export class PromotionService {
         };
     }
 
+    async validateCouponCode(couponCode: string): Promise<boolean> {
+        const promotion = await this.connection.getRepository(Promotion).findOne({
+            where: {
+                couponCode,
+                enabled: true,
+                deletedAt: null,
+            },
+        });
+        if (!promotion) {
+            throw new CouponCodeInvalidError(couponCode);
+        }
+        if (promotion.endsAt && +promotion.endsAt < +new Date()) {
+            throw new CouponCodeExpiredError(couponCode);
+        }
+        return true;
+    }
+
     /**
      * Converts the input values of the "create" and "update" mutations into the format expected by the AdjustmentSource entity.
      */

+ 1 - 11
packages/dev-server/dev-config.ts

@@ -36,17 +36,7 @@ export const devConfig: VendureConfig = {
     paymentOptions: {
         paymentMethodHandlers: [examplePaymentHandler],
     },
-    customFields: {
-        Product: [
-            { name: 'length', type: 'int', min: 0, max: 100 },
-            {
-                name: 'offerImageId',
-                label: [{ languageCode: LanguageCode.en, value: 'Offer image' }],
-                type: 'string',
-            },
-        ],
-        ProductVariant: [{ name: 'length', type: 'int', min: 0, max: 100 }],
-    },
+    customFields: {},
     logger: new DefaultLogger({ level: LogLevel.Info }),
     importExportOptions: {
         importAssetsDir: path.join(__dirname, 'import-assets'),

File diff suppressed because it is too large
+ 0 - 0
schema-admin.json


File diff suppressed because it is too large
+ 0 - 0
schema-shop.json


Some files were not shown because too many files changed in this diff