Browse Source

feat(admin-ui): Implement cancellation & refund flows

Relates to #121
Michael Bromley 6 years ago
parent
commit
9295a90330
26 changed files with 855 additions and 89 deletions
  1. 108 24
      admin-ui/src/app/common/generated-types.ts
  2. 41 2
      admin-ui/src/app/data/definitions/order-definitions.ts
  3. 20 2
      admin-ui/src/app/data/providers/order-data.service.ts
  4. 65 0
      admin-ui/src/app/order/components/cancel-order-dialog/cancel-order-dialog.component.html
  5. 36 0
      admin-ui/src/app/order/components/cancel-order-dialog/cancel-order-dialog.component.scss
  6. 51 0
      admin-ui/src/app/order/components/cancel-order-dialog/cancel-order-dialog.component.ts
  7. 3 7
      admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.ts
  8. 0 1
      admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.html
  9. 1 1
      admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.ts
  10. 3 0
      admin-ui/src/app/order/components/line-refunds/line-refunds.component.html
  11. 5 0
      admin-ui/src/app/order/components/line-refunds/line-refunds.component.scss
  12. 17 0
      admin-ui/src/app/order/components/line-refunds/line-refunds.component.ts
  13. 29 35
      admin-ui/src/app/order/components/order-detail/order-detail.component.html
  14. 6 7
      admin-ui/src/app/order/components/order-detail/order-detail.component.scss
  15. 79 9
      admin-ui/src/app/order/components/order-detail/order-detail.component.ts
  16. 45 0
      admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.html
  17. 14 0
      admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.scss
  18. 20 0
      admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.ts
  19. 4 0
      admin-ui/src/app/order/components/payment-state-label/payment-state-label.component.html
  20. 3 0
      admin-ui/src/app/order/components/payment-state-label/payment-state-label.component.scss
  21. 22 0
      admin-ui/src/app/order/components/payment-state-label/payment-state-label.component.ts
  22. 114 0
      admin-ui/src/app/order/components/refund-order-dialog/refund-order-dialog.component.html
  23. 51 0
      admin-ui/src/app/order/components/refund-order-dialog/refund-order-dialog.component.scss
  24. 85 0
      admin-ui/src/app/order/components/refund-order-dialog/refund-order-dialog.component.ts
  25. 11 1
      admin-ui/src/app/order/order.module.ts
  26. 22 0
      admin-ui/src/i18n-messages/en.json

+ 108 - 24
admin-ui/src/app/common/generated-types.ts

@@ -172,6 +172,11 @@ export type Cancellation = Node & StockMovement & {
   orderLine: OrderLine,
 };
 
+export type CancelOrderInput = {
+  lines: Array<OrderLineInput>,
+  reason?: Maybe<Scalars['String']>,
+};
+
 export type Channel = Node & {
   __typename?: 'Channel',
   id: Scalars['ID'],
@@ -449,12 +454,6 @@ export type CreateFacetValueWithFacetInput = {
   translations: Array<FacetValueTranslationInput>,
 };
 
-export type CreateFulfillmentInput = {
-  lines: Array<FulfillmentLineInput>,
-  method: Scalars['String'],
-  trackingCode?: Maybe<Scalars['String']>,
-};
-
 export type CreateProductInput = {
   featuredAssetId?: Maybe<Scalars['ID']>,
   assetIds?: Maybe<Array<Scalars['ID']>>,
@@ -1050,9 +1049,10 @@ export type Fulfillment = Node & {
   trackingCode?: Maybe<Scalars['String']>,
 };
 
-export type FulfillmentLineInput = {
-  orderLineId: Scalars['ID'],
-  quantity: Scalars['Int'],
+export type FulfillOrderInput = {
+  lines: Array<OrderLineInput>,
+  method: Scalars['String'],
+  trackingCode?: Maybe<Scalars['String']>,
 };
 
 export type GlobalSettings = {
@@ -1541,10 +1541,13 @@ export type Mutation = {
   updateFacetValues: Array<FacetValue>,
   /** Delete one or more FacetValues */
   deleteFacetValues: Array<DeletionResponse>,
-  updateGlobalSettings: GlobalSettings,
   importProducts?: Maybe<ImportInfo>,
-  settlePayment?: Maybe<Payment>,
-  createFulfillment?: Maybe<Fulfillment>,
+  updateGlobalSettings: GlobalSettings,
+  settlePayment: Payment,
+  fulfillOrder: Fulfillment,
+  cancelOrder: Order,
+  refundOrder: Refund,
+  settleRefund: Refund,
   /** Update an existing PaymentMethod */
   updatePaymentMethod: PaymentMethod,
   /** Create a new ProductOptionGroup */
@@ -1757,13 +1760,13 @@ export type MutationDeleteFacetValuesArgs = {
 };
 
 
-export type MutationUpdateGlobalSettingsArgs = {
-  input: UpdateGlobalSettingsInput
+export type MutationImportProductsArgs = {
+  csvFile: Scalars['Upload']
 };
 
 
-export type MutationImportProductsArgs = {
-  csvFile: Scalars['Upload']
+export type MutationUpdateGlobalSettingsArgs = {
+  input: UpdateGlobalSettingsInput
 };
 
 
@@ -1772,8 +1775,23 @@ export type MutationSettlePaymentArgs = {
 };
 
 
-export type MutationCreateFulfillmentArgs = {
-  input: CreateFulfillmentInput
+export type MutationFulfillOrderArgs = {
+  input: FulfillOrderInput
+};
+
+
+export type MutationCancelOrderArgs = {
+  input: CancelOrderInput
+};
+
+
+export type MutationRefundOrderArgs = {
+  input: RefundOrderInput
+};
+
+
+export type MutationSettleRefundArgs = {
+  input: SettleRefundInput
 };
 
 
@@ -2007,12 +2025,14 @@ export type OrderItem = Node & {
   id: Scalars['ID'],
   createdAt: Scalars['DateTime'],
   updatedAt: Scalars['DateTime'],
+  cancelled: Scalars['Boolean'],
   unitPrice: Scalars['Int'],
   unitPriceWithTax: Scalars['Int'],
   unitPriceIncludesTax: Scalars['Boolean'],
   taxRate: Scalars['Float'],
   adjustments: Array<Adjustment>,
   fulfillment?: Maybe<Fulfillment>,
+  refundId?: Maybe<Scalars['ID']>,
 };
 
 export type OrderLine = Node & {
@@ -2032,6 +2052,11 @@ export type OrderLine = Node & {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type OrderLineInput = {
+  orderLineId: Scalars['ID'],
+  quantity: Scalars['Int'],
+};
+
 export type OrderList = PaginatedList & {
   __typename?: 'OrderList',
   items: Array<Order>,
@@ -2074,6 +2099,7 @@ export type Payment = Node & {
   amount: Scalars['Int'],
   state: Scalars['String'],
   transactionId?: Maybe<Scalars['String']>,
+  refunds: Array<Refund>,
   metadata?: Maybe<Scalars['JSON']>,
 };
 
@@ -2646,6 +2672,31 @@ export type QueryZoneArgs = {
   id: Scalars['ID']
 };
 
+export type Refund = Node & {
+  __typename?: 'Refund',
+  id: Scalars['ID'],
+  createdAt: Scalars['DateTime'],
+  updatedAt: Scalars['DateTime'],
+  items: Scalars['Int'],
+  shipping: Scalars['Int'],
+  adjustment: Scalars['Int'],
+  total: Scalars['Int'],
+  method?: Maybe<Scalars['String']>,
+  state: Scalars['String'],
+  transactionId?: Maybe<Scalars['String']>,
+  orderItems: Array<OrderItem>,
+  paymentId: Scalars['ID'],
+  metadata?: Maybe<Scalars['JSON']>,
+};
+
+export type RefundOrderInput = {
+  lines: Array<OrderLineInput>,
+  shipping: Scalars['Int'],
+  adjustment: Scalars['Int'],
+  paymentId: Scalars['ID'],
+  reason?: Maybe<Scalars['String']>,
+};
+
 export type Return = Node & StockMovement & {
   __typename?: 'Return',
   id: Scalars['ID'],
@@ -2767,6 +2818,11 @@ export type ServerConfig = {
   customFields?: Maybe<Scalars['JSON']>,
 };
 
+export type SettleRefundInput = {
+  id: Scalars['ID'],
+  transactionId: Scalars['String'],
+};
+
 export type ShippingMethod = Node & {
   __typename?: 'ShippingMethod',
   id: Scalars['ID'],
@@ -3433,7 +3489,7 @@ export type OrderFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'crea
 
 export type FulfillmentFragment = ({ __typename?: 'Fulfillment' } & Pick<Fulfillment, 'id' | 'createdAt' | 'updatedAt' | 'method' | 'trackingCode'>);
 
-export type OrderWithLinesFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'active' | 'subTotal' | 'subTotalBeforeTax' | 'totalBeforeTax' | 'currencyCode' | 'shipping' | 'total'> & { customer: Maybe<({ __typename?: 'Customer' } & Pick<Customer, 'id' | 'firstName' | 'lastName'>)>, lines: Array<({ __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'totalPrice'> & { featuredAsset: Maybe<({ __typename?: 'Asset' } & Pick<Asset, 'preview'>)>, productVariant: ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'name' | 'sku'>), items: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate'> & { fulfillment: Maybe<({ __typename?: 'Fulfillment' } & FulfillmentFragment)> })> })>, adjustments: Array<({ __typename?: 'Adjustment' } & AdjustmentFragment)>, shippingMethod: Maybe<({ __typename?: 'ShippingMethod' } & Pick<ShippingMethod, 'id' | 'code' | 'description'>)>, shippingAddress: Maybe<({ __typename?: 'OrderAddress' } & ShippingAddressFragment)>, payments: Maybe<Array<({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>)>>, fulfillments: Maybe<Array<({ __typename?: 'Fulfillment' } & FulfillmentFragment)>> });
+export type OrderWithLinesFragment = ({ __typename?: 'Order' } & Pick<Order, 'id' | 'createdAt' | 'updatedAt' | 'code' | 'state' | 'active' | 'subTotal' | 'subTotalBeforeTax' | 'totalBeforeTax' | 'currencyCode' | 'shipping' | 'total'> & { customer: Maybe<({ __typename?: 'Customer' } & Pick<Customer, 'id' | 'firstName' | 'lastName'>)>, lines: Array<({ __typename?: 'OrderLine' } & Pick<OrderLine, 'id' | 'unitPrice' | 'unitPriceWithTax' | 'quantity' | 'totalPrice'> & { featuredAsset: Maybe<({ __typename?: 'Asset' } & Pick<Asset, 'preview'>)>, productVariant: ({ __typename?: 'ProductVariant' } & Pick<ProductVariant, 'id' | 'name' | 'sku'>), items: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id' | 'unitPrice' | 'unitPriceIncludesTax' | 'unitPriceWithTax' | 'taxRate' | 'refundId' | 'cancelled'> & { fulfillment: Maybe<({ __typename?: 'Fulfillment' } & FulfillmentFragment)> })> })>, adjustments: Array<({ __typename?: 'Adjustment' } & AdjustmentFragment)>, shippingMethod: Maybe<({ __typename?: 'ShippingMethod' } & Pick<ShippingMethod, 'id' | 'code' | 'description'>)>, shippingAddress: Maybe<({ __typename?: 'OrderAddress' } & ShippingAddressFragment)>, payments: Maybe<Array<({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'createdAt' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'> & { refunds: Array<({ __typename?: 'Refund' } & Pick<Refund, 'id' | 'createdAt' | 'state' | 'items' | 'adjustment' | 'total' | 'paymentId' | 'method'> & { orderItems: Array<({ __typename?: 'OrderItem' } & Pick<OrderItem, 'id'>)> })> })>>, fulfillments: Maybe<Array<({ __typename?: 'Fulfillment' } & FulfillmentFragment)>> });
 
 export type GetOrderListQueryVariables = {
   options?: Maybe<OrderListOptions>
@@ -3454,14 +3510,28 @@ export type SettlePaymentMutationVariables = {
 };
 
 
-export type SettlePaymentMutation = ({ __typename?: 'Mutation' } & { settlePayment: Maybe<({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>)> });
+export type SettlePaymentMutation = ({ __typename?: 'Mutation' } & { settlePayment: ({ __typename?: 'Payment' } & Pick<Payment, 'id' | 'transactionId' | 'amount' | 'method' | 'state' | 'metadata'>) });
 
 export type CreateFulfillmentMutationVariables = {
-  input: CreateFulfillmentInput
+  input: FulfillOrderInput
+};
+
+
+export type CreateFulfillmentMutation = ({ __typename?: 'Mutation' } & { fulfillOrder: ({ __typename?: 'Fulfillment' } & FulfillmentFragment) });
+
+export type CancelOrderMutationVariables = {
+  input: CancelOrderInput
 };
 
 
-export type CreateFulfillmentMutation = ({ __typename?: 'Mutation' } & { createFulfillment: Maybe<({ __typename?: 'Fulfillment' } & FulfillmentFragment)> });
+export type CancelOrderMutation = ({ __typename?: 'Mutation' } & { cancelOrder: ({ __typename?: 'Order' } & OrderWithLinesFragment) });
+
+export type RefundOrderMutationVariables = {
+  input: RefundOrderInput
+};
+
+
+export type RefundOrderMutation = ({ __typename?: 'Mutation' } & { refundOrder: ({ __typename?: 'Refund' } & Pick<Refund, 'id' | 'state' | 'items' | 'shipping' | 'adjustment' | 'transactionId' | 'paymentId'>) });
 
 export type AssetFragment = ({ __typename?: 'Asset' } & Pick<Asset, 'id' | 'createdAt' | 'name' | 'fileSize' | 'mimeType' | 'type' | 'preview' | 'source'>);
 
@@ -4220,6 +4290,8 @@ export namespace OrderWithLines {
   export type ShippingMethod = (NonNullable<OrderWithLinesFragment['shippingMethod']>);
   export type ShippingAddress = ShippingAddressFragment;
   export type Payments = (NonNullable<(NonNullable<OrderWithLinesFragment['payments']>)[0]>);
+  export type Refunds = (NonNullable<(NonNullable<(NonNullable<OrderWithLinesFragment['payments']>)[0]>)['refunds'][0]>);
+  export type OrderItems = (NonNullable<(NonNullable<(NonNullable<(NonNullable<OrderWithLinesFragment['payments']>)[0]>)['refunds'][0]>)['orderItems'][0]>);
   export type Fulfillments = FulfillmentFragment;
 }
 
@@ -4239,13 +4311,25 @@ export namespace GetOrder {
 export namespace SettlePayment {
   export type Variables = SettlePaymentMutationVariables;
   export type Mutation = SettlePaymentMutation;
-  export type SettlePayment = (NonNullable<SettlePaymentMutation['settlePayment']>);
+  export type SettlePayment = SettlePaymentMutation['settlePayment'];
 }
 
 export namespace CreateFulfillment {
   export type Variables = CreateFulfillmentMutationVariables;
   export type Mutation = CreateFulfillmentMutation;
-  export type CreateFulfillment = FulfillmentFragment;
+  export type FulfillOrder = FulfillmentFragment;
+}
+
+export namespace CancelOrder {
+  export type Variables = CancelOrderMutationVariables;
+  export type Mutation = CancelOrderMutation;
+  export type CancelOrder = OrderWithLinesFragment;
+}
+
+export namespace RefundOrder {
+  export type Variables = RefundOrderMutationVariables;
+  export type Mutation = RefundOrderMutation;
+  export type RefundOrder = RefundOrderMutation['refundOrder'];
 }
 
 export namespace Asset {

+ 41 - 2
admin-ui/src/app/data/definitions/order-definitions.ts

@@ -82,6 +82,8 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
                 unitPriceIncludesTax
                 unitPriceWithTax
                 taxRate
+                refundId
+                cancelled
                 fulfillment {
                     ...Fulfillment
                 }
@@ -106,11 +108,25 @@ export const ORDER_WITH_LINES_FRAGMENT = gql`
         }
         payments {
             id
+            createdAt
             transactionId
             amount
             method
             state
             metadata
+            refunds {
+                id
+                createdAt
+                state
+                items
+                adjustment
+                total
+                paymentId
+                method
+                orderItems {
+                    id
+                }
+            }
         }
         fulfillments {
             ...Fulfillment
@@ -157,10 +173,33 @@ export const SETTLE_PAYMENT = gql`
 `;
 
 export const CREATE_FULFILLMENT = gql`
-    mutation CreateFulfillment($input: CreateFulfillmentInput!) {
-        createFulfillment(input: $input) {
+    mutation CreateFulfillment($input: FulfillOrderInput!) {
+        fulfillOrder(input: $input) {
             ...Fulfillment
         }
     }
     ${FULFILLMENT_FRAGMENT}
 `;
+
+export const CANCEL_ORDER = gql`
+    mutation CancelOrder($input: CancelOrderInput!) {
+        cancelOrder(input: $input) {
+            ...OrderWithLines
+        }
+    }
+    ${ORDER_WITH_LINES_FRAGMENT}
+`;
+
+export const REFUND_ORDER = gql`
+    mutation RefundOrder($input: RefundOrderInput!) {
+        refundOrder(input: $input) {
+            id
+            state
+            items
+            shipping
+            adjustment
+            transactionId
+            paymentId
+        }
+    }
+`;

+ 20 - 2
admin-ui/src/app/data/providers/order-data.service.ts

@@ -1,14 +1,20 @@
 import {
+    CancelOrder,
+    CancelOrderInput,
     CreateFulfillment,
-    CreateFulfillmentInput,
+    FulfillOrderInput,
     GetOrder,
     GetOrderList,
+    RefundOrder,
+    RefundOrderInput,
     SettlePayment,
 } from '../../common/generated-types';
 import {
+    CANCEL_ORDER,
     CREATE_FULFILLMENT,
     GET_ORDER,
     GET_ORDERS_LIST,
+    REFUND_ORDER,
     SETTLE_PAYMENT,
 } from '../definitions/order-definitions';
 
@@ -36,7 +42,7 @@ export class OrderDataService {
         });
     }
 
-    createFullfillment(input: CreateFulfillmentInput) {
+    createFullfillment(input: FulfillOrderInput) {
         return this.baseDataService.mutate<CreateFulfillment.Mutation, CreateFulfillment.Variables>(
             CREATE_FULFILLMENT,
             {
@@ -44,4 +50,16 @@ export class OrderDataService {
             },
         );
     }
+
+    cancelOrder(input: CancelOrderInput) {
+        return this.baseDataService.mutate<CancelOrder.Mutation, CancelOrder.Variables>(CANCEL_ORDER, {
+            input,
+        });
+    }
+
+    refundOrder(input: RefundOrderInput) {
+        return this.baseDataService.mutate<RefundOrder.Mutation, RefundOrder.Variables>(REFUND_ORDER, {
+            input,
+        });
+    }
 }

+ 65 - 0
admin-ui/src/app/order/components/cancel-order-dialog/cancel-order-dialog.component.html

@@ -0,0 +1,65 @@
+<ng-template vdrDialogTitle>{{ 'order.cancel-order' | translate }}</ng-template>
+
+<div class="fulfillment-wrapper">
+    <div class="order-lines">
+        <table class="table">
+            <thead>
+                <tr>
+                    <th></th>
+                    <th>{{ 'order.product-name' | translate }}</th>
+                    <th>{{ 'order.product-sku' | translate }}</th>
+                    <th>{{ 'order.quantity' | translate }}</th>
+                    <th>{{ 'order.unit-price' | translate }}</th>
+                    <th>{{ 'order.cancel' | translate }}</th>
+                </tr>
+            </thead>
+            <tr
+                *ngFor="let line of order.lines"
+                class="order-line"
+                [class.is-cancelled]="line.quantity === 0"
+            >
+                <td class="align-middle thumb">
+                    <img [src]="line.featuredAsset.preview + '?preset=tiny'" />
+                </td>
+                <td class="align-middle name">{{ line.productVariant.name }}</td>
+                <td class="align-middle sku">{{ line.productVariant.sku }}</td>
+                <td class="align-middle quantity">{{ line.quantity }}</td>
+                <td class="align-middle quantity">
+                    {{ line.unitPriceWithTax / 100 | currency: order.currencyCode }}
+                </td>
+                <td class="align-middle fulfil">
+                    <input
+                        *ngIf="line.quantity > 0"
+                        [(ngModel)]="lineQuantities[line.id]"
+                        type="number"
+                        [max]="line.quantity"
+                        min="0"
+                    />
+                </td>
+            </tr>
+        </table>
+    </div>
+    <div class="cancellation-details">
+        <label class="clr-control-label">{{ 'order.cancellation-reason' | translate }}</label>
+        <ng-select
+            [items]="reasons"
+            bindLabel="name"
+            autofocus
+            bindValue="id"
+            [addTag]="true"
+            [(ngModel)]="reason"
+        ></ng-select>
+    </div>
+</div>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button
+        type="submit"
+        (click)="select()"
+        [disabled]="!reason || selectionCount === 0"
+        class="btn btn-primary"
+    >
+        {{ 'order.cancel' | translate }}
+    </button>
+</ng-template>

+ 36 - 0
admin-ui/src/app/order/components/cancel-order-dialog/cancel-order-dialog.component.scss

@@ -0,0 +1,36 @@
+@import "variables";
+
+:host {
+    height: 100%;
+    display: flex;
+    min-height: 64vh;
+}
+.fulfillment-wrapper {
+    flex: 1;
+    @media screen and (min-width: $breakpoint-small) {
+        display: flex;
+        flex-direction: row;
+    }
+
+    .cancellation-details {
+        @media screen and (min-width: $breakpoint-small) {
+            margin-top: 0;
+            margin-left: 24px;
+            width: 250px;
+        }
+    }
+    .order-lines {
+        flex: 1;
+        overflow-y: auto;
+        table {
+            margin-top: 0;
+        }
+    }
+    tr.ignore {
+        color: $color-grey-300;
+    }
+    .is-cancelled td {
+        text-decoration: line-through;
+        background-color: $color-grey-200;
+    }
+}

+ 51 - 0
admin-ui/src/app/order/components/cancel-order-dialog/cancel-order-dialog.component.ts

@@ -0,0 +1,51 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { _ } from 'src/app/core/providers/i18n/mark-for-extraction';
+
+import { CancelOrderInput, OrderWithLinesFragment } from '../../../common/generated-types';
+import { I18nService } from '../../../core/providers/i18n/i18n.service';
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-cancel-order-dialog',
+    templateUrl: './cancel-order-dialog.component.html',
+    styleUrls: ['./cancel-order-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class CancelOrderDialogComponent implements OnInit, Dialog<CancelOrderInput> {
+    order: OrderWithLinesFragment;
+    resolveWith: (result?: CancelOrderInput) => void;
+    reason: string;
+    lineQuantities: { [lineId: string]: number } = {};
+    reasons: string[] = [_('order.cancel-reason-customer-request'), _('order.cancel-reason-not-available')];
+
+    get selectionCount(): number {
+        return Object.values(this.lineQuantities).reduce((sum, n) => sum + n, 0);
+    }
+
+    constructor(private i18nService: I18nService) {
+        this.reasons = this.reasons.map(r => this.i18nService.translate(r));
+    }
+
+    ngOnInit() {
+        this.lineQuantities = this.order.lines.reduce((result, line) => {
+            return { ...result, [line.id]: 0 };
+        }, {});
+    }
+
+    select() {
+        const lines = Object.entries(this.lineQuantities)
+            .map(([orderLineId, quantity]) => ({
+                orderLineId,
+                quantity,
+            }))
+            .filter(l => 0 < l.quantity);
+        this.resolveWith({
+            lines,
+            reason: this.reason,
+        });
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+}

+ 3 - 7
admin-ui/src/app/order/components/fulfill-order-dialog/fulfill-order-dialog.component.ts

@@ -1,10 +1,6 @@
 import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
 
-import {
-    CreateFulfillmentInput,
-    OrderWithLines,
-    OrderWithLinesFragment,
-} from '../../../common/generated-types';
+import { FulfillOrderInput, OrderWithLines, OrderWithLinesFragment } from '../../../common/generated-types';
 import { Dialog } from '../../../shared/providers/modal/modal.service';
 
 @Component({
@@ -13,9 +9,9 @@ import { Dialog } from '../../../shared/providers/modal/modal.service';
     styleUrls: ['./fulfill-order-dialog.component.scss'],
     changeDetection: ChangeDetectionStrategy.OnPush,
 })
-export class FulfillOrderDialogComponent implements Dialog<CreateFulfillmentInput>, OnInit {
+export class FulfillOrderDialogComponent implements Dialog<FulfillOrderInput>, OnInit {
     order: OrderWithLinesFragment;
-    resolveWith: (result?: CreateFulfillmentInput) => void;
+    resolveWith: (result?: FulfillOrderInput) => void;
     method = '';
     trackingCode = '';
     fulfillmentQuantities: { [lineId: string]: number } = {};

+ 0 - 1
admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.html

@@ -1,4 +1,3 @@
-{{ line.quantity }}
 <vdr-dropdown class="search-settings-menu" *ngIf="fulfilledCount || orderState === 'PartiallyFulfilled'">
     <button type="button" class="icon-button" vdrDropdownTrigger>
         <clr-icon *ngIf="fulfillmentStatus === 'full'" class="item-fulfilled" shape="check-circle"></clr-icon>

+ 1 - 1
admin-ui/src/app/order/components/line-fulfillment/line-fulfillment.component.ts

@@ -21,7 +21,7 @@ export class LineFulfillmentComponent implements OnChanges {
     ngOnChanges(changes: SimpleChanges): void {
         if (this.line) {
             this.fulfilledCount = this.getFulfilledCount(this.line);
-            this.fulfillmentStatus = this.getFulfillmentStatus(this.fulfilledCount, this.line.quantity);
+            this.fulfillmentStatus = this.getFulfillmentStatus(this.fulfilledCount, this.line.items.length);
             this.fulfillments = this.getFulfillments(this.line);
         }
     }

+ 3 - 0
admin-ui/src/app/order/components/line-refunds/line-refunds.component.html

@@ -0,0 +1,3 @@
+<span *ngIf="getRefundedCount()" [title]="'order.refunded-count' | translate: { count: getRefundedCount() }">
+    <clr-icon shape="asterisk" class="is-solid"></clr-icon>
+</span>

+ 5 - 0
admin-ui/src/app/order/components/line-refunds/line-refunds.component.scss

@@ -0,0 +1,5 @@
+@import "variables";
+
+:host {
+    color: $color-error-500;
+}

+ 17 - 0
admin-ui/src/app/order/components/line-refunds/line-refunds.component.ts

@@ -0,0 +1,17 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+import { OrderWithLines } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-line-refunds',
+    templateUrl: './line-refunds.component.html',
+    styleUrls: ['./line-refunds.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class LineRefundsComponent {
+    @Input() line: OrderWithLines.Lines;
+
+    getRefundedCount(): number {
+        return this.line.items.filter(i => i.refundId != null && !i.cancelled).length;
+    }
+}

+ 29 - 35
admin-ui/src/app/order/components/order-detail/order-detail.component.html

@@ -17,6 +17,23 @@
         >
             {{ 'order.fulfill-order' | translate }}
         </button>
+        <vdr-dropdown>
+            <button class="icon-button" vdrDropdownTrigger>
+                <clr-icon shape="ellipsis-vertical"></clr-icon>
+            </button>
+            <vdr-dropdown-menu vdrPosition="bottom-right">
+                <button
+                    type="button"
+                    class="btn"
+                    vdrDropdownItem
+                    *ngIf="!order.active"
+                    (click)="cancelOrRefund(order)"
+                >
+                    <clr-icon shape="error-standard" class="is-error"></clr-icon>
+                    {{ 'order.refund-and-cancel-order' | translate }}
+                </button>
+            </vdr-dropdown-menu>
+        </vdr-dropdown>
     </vdr-ab-right>
 </vdr-action-bar>
 
@@ -34,7 +51,11 @@
                         <th>{{ 'order.total' | translate }}</th>
                     </tr>
                 </thead>
-                <tr *ngFor="let line of order.lines" class="order-line">
+                <tr
+                    *ngFor="let line of order.lines"
+                    class="order-line"
+                    [class.is-cancelled]="line.quantity === 0"
+                >
                     <td class="align-middle thumb">
                         <img [src]="line.featuredAsset.preview + '?preset=tiny'" />
                     </td>
@@ -44,6 +65,8 @@
                         {{ line.unitPriceWithTax / 100 | currency: order.currencyCode }}
                     </td>
                     <td class="align-middle quantity">
+                        {{ line.quantity }}
+                        <vdr-line-refunds [line]="line"></vdr-line-refunds>
                         <vdr-line-fulfillment [line]="line" [orderState]="order.state"></vdr-line-fulfillment>
                     </td>
                     <td class="align-middle total">
@@ -94,40 +117,11 @@
                 </div>
             </div>
             <ng-container *ngIf="order.payments && order.payments.length">
-                <div class="card" *ngFor="let payment of order.payments">
-                    <div class="card-header">
-                        {{ 'order.payment' | translate }}
-                    </div>
-                    <div class="card-block">
-                        <vdr-labeled-data [label]="'order.payment-state' | translate">
-                            {{ payment.state }}
-                        </vdr-labeled-data>
-                        <vdr-labeled-data [label]="'order.payment-method' | translate">
-                            {{ payment.method }}
-                        </vdr-labeled-data>
-                        <vdr-labeled-data [label]="'order.amount' | translate">
-                            {{ payment.amount / 100 | currency: order.currencyCode }}
-                        </vdr-labeled-data>
-                        <vdr-labeled-data [label]="'order.transaction-id' | translate">
-                            {{ payment.transactionId }}
-                        </vdr-labeled-data>
-                        <vdr-labeled-data [label]="'order.payment-metadata' | translate">
-                            <ul class="payment-metadata">
-                                <li *ngFor="let entry of getPaymentMetadata(payment)">
-                                    <span class="metadata-prop">{{ entry[0] }}:</span>
-                                    {{ entry[1] }}
-                                </li>
-                            </ul>
-                        </vdr-labeled-data>
-                    </div>
-                    <div class="card-footer">
-                        <ng-container *ngIf="payment.state === 'Authorized'">
-                            <button class="btn btn-sm btn-primary" (click)="settlePayment(payment)">
-                                {{ 'order.settle-payment' | translate }}
-                            </button>
-                        </ng-container>
-                    </div>
-                </div>
+                <vdr-order-payment-detail
+                    *ngFor="let payment of order.payments"
+                    [currencyCode]="order.currencyCode"
+                    [payment]="payment"
+                ></vdr-order-payment-detail>
             </ng-container>
             <ng-container *ngIf="order.fulfillments && order.fulfillments.length">
                 <div class="card">

+ 6 - 7
admin-ui/src/app/order/components/order-detail/order-detail.component.scss

@@ -1,17 +1,11 @@
 @import "variables";
+@import "mixins";
 
 .date-detail {
     color: $color-grey-500;
     padding-left: 6px;
 }
 
-ul.payment-metadata {
-   list-style-type: none;
-}
-.metadata-prop {
-    color: $color-grey-500;
-}
-
 .shipping-address {
     list-style-type: none;
     line-height: 1.3em;
@@ -19,6 +13,11 @@ ul.payment-metadata {
 
 .order-lines {
 
+    .is-cancelled td {
+        text-decoration: line-through;
+        background-color: $color-grey-200;
+    }
+
     .sub-total td {
         border-top: 1px dashed $color-grey-300;
     }

+ 79 - 9
admin-ui/src/app/order/components/order-detail/order-detail.component.ts

@@ -1,8 +1,9 @@
 import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
 import { FormGroup } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { of } from 'rxjs';
+import { Observable, of } from 'rxjs';
 import { switchMap, take } from 'rxjs/operators';
+import { omit } from 'shared/omit';
 import { _ } from 'src/app/core/providers/i18n/mark-for-extraction';
 
 import { BaseDetailComponent } from '../../../common/base-detail.component';
@@ -11,7 +12,9 @@ import { NotificationService } from '../../../core/providers/notification/notifi
 import { DataService } from '../../../data/providers/data.service';
 import { ServerConfigService } from '../../../data/server-config';
 import { ModalService } from '../../../shared/providers/modal/modal.service';
+import { CancelOrderDialogComponent } from '../cancel-order-dialog/cancel-order-dialog.component';
 import { FulfillOrderDialogComponent } from '../fulfill-order-dialog/fulfill-order-dialog.component';
+import { RefundOrderDialogComponent } from '../refund-order-dialog/refund-order-dialog.component';
 
 @Component({
     selector: 'vdr-order-detail',
@@ -51,10 +54,6 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderWithLines.Fra
             .filter(line => !!line);
     }
 
-    getPaymentMetadata(payment: OrderWithLines.Payments) {
-        return Object.entries(payment.metadata);
-    }
-
     settlePayment(payment: OrderWithLines.Payments) {
         this.dataService.order.settlePayment(payment.id).subscribe(({ settlePayment }) => {
             if (settlePayment) {
@@ -87,21 +86,92 @@ export class OrderDetailComponent extends BaseDetailComponent<OrderWithLines.Fra
                         return of(undefined);
                     }
                 }),
-                switchMap(result => {
-                    if (result) {
-                        return this.dataService.order.getOrder(this.id).single$;
+                switchMap(result => this.refetchOrder(result)),
+            )
+            .subscribe(result => {
+                if (result) {
+                    this.notificationService.success(_('order.create-fulfillment-success'));
+                }
+            });
+    }
+
+    cancelOrRefund(order: OrderWithLines.Fragment) {
+        if (order.state === 'PaymentAuthorized') {
+            this.cancelOrder(order);
+        } else {
+            this.refundOrder(order);
+        }
+    }
+
+    private cancelOrder(order: OrderWithLines.Fragment) {
+        this.modalService
+            .fromComponent(CancelOrderDialogComponent, {
+                size: 'xl',
+                locals: {
+                    order,
+                },
+            })
+            .pipe(
+                switchMap(input => {
+                    if (input) {
+                        return this.dataService.order.cancelOrder(input);
                     } else {
                         return of(undefined);
                     }
                 }),
+                switchMap(result => this.refetchOrder(result)),
             )
             .subscribe(result => {
                 if (result) {
-                    this.notificationService.success(_('order.create-fulfillment-success'));
+                    this.notificationService.success(_('order.cancelled-order-success'));
                 }
             });
     }
 
+    private refundOrder(order: OrderWithLines.Fragment) {
+        this.modalService
+            .fromComponent(RefundOrderDialogComponent, {
+                size: 'xl',
+                locals: {
+                    order,
+                },
+            })
+            .pipe(
+                switchMap(input => {
+                    if (input) {
+                        return this.dataService.order.refundOrder(omit(input, ['cancel'])).pipe(
+                            switchMap(result => {
+                                if (input.cancel.length) {
+                                    return this.dataService.order.cancelOrder({
+                                        lines: input.cancel,
+                                        reason: input.reason,
+                                    });
+                                } else {
+                                    return of(result);
+                                }
+                            }),
+                        );
+                    } else {
+                        return of(undefined);
+                    }
+                }),
+                switchMap(result => this.refetchOrder(result)),
+            )
+            .subscribe(result => {
+                if (result) {
+                    this.notificationService.success(_('order.refund-order-success'));
+                }
+            });
+    }
+
+    private refetchOrder(result: object | undefined) {
+        if (result) {
+            return this.dataService.order.getOrder(this.id).single$;
+        } else {
+            return of(undefined);
+        }
+    }
+
     protected setFormValues(entity: Order.Fragment): void {
         // empty
     }

+ 45 - 0
admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.html

@@ -0,0 +1,45 @@
+<div class="card">
+    <div class="card-header payment-header">
+        <div>{{ 'order.payment' | translate }}</div>
+        <div class="payment-state">
+            <vdr-payment-state-label [state]="payment.state"></vdr-payment-state-label>
+        </div>
+    </div>
+    <div class="card-block">
+        <vdr-labeled-data [label]="'order.payment-method' | translate">
+            {{ payment.method }}
+        </vdr-labeled-data>
+        <vdr-labeled-data [label]="'order.amount' | translate">
+            {{ payment.amount / 100 | currency: currencyCode }}
+        </vdr-labeled-data>
+        <vdr-labeled-data [label]="'order.transaction-id' | translate">
+            {{ payment.transactionId }}
+        </vdr-labeled-data>
+        <vdr-labeled-data [label]="'order.payment-metadata' | translate">
+            <ul class="payment-metadata">
+                <li *ngFor="let entry of getPaymentMetadata(payment)">
+                    <span class="metadata-prop">{{ entry[0] }}:</span>
+                    {{ entry[1] }}
+                </li>
+            </ul>
+        </vdr-labeled-data>
+    </div>
+    <ng-container *ngFor="let refund of payment.refunds">
+        <div class="card-header">{{ 'order.refund' | translate }} #{{ refund.id }}</div>
+        <div class="card-block">
+            <vdr-labeled-data [label]="'common.created-at' | translate">
+                {{ refund.createdAt | date: 'medium' }}
+            </vdr-labeled-data>
+            <vdr-labeled-data [label]="'order.refund-total' | translate">
+                {{ refund.total / 100 | currency: currencyCode }}
+            </vdr-labeled-data>
+        </div>
+    </ng-container>
+    <div class="card-footer">
+        <ng-container *ngIf="payment.state === 'Authorized'">
+            <button class="btn btn-sm btn-primary" (click)="settlePayment.emit(payment)">
+                {{ 'order.settle-payment' | translate }}
+            </button>
+        </ng-container>
+    </div>
+</div>

+ 14 - 0
admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.scss

@@ -0,0 +1,14 @@
+@import "variables";
+
+.payment-header {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+}
+
+ul.payment-metadata {
+    list-style-type: none;
+}
+.metadata-prop {
+    color: $color-grey-500;
+}

+ 20 - 0
admin-ui/src/app/order/components/order-payment-detail/order-payment-detail.component.ts

@@ -0,0 +1,20 @@
+import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core';
+import { CurrencyCode } from 'shared/generated-types';
+
+import { OrderWithLines } from '../../../common/generated-types';
+
+@Component({
+    selector: 'vdr-order-payment-detail',
+    templateUrl: './order-payment-detail.component.html',
+    styleUrls: ['./order-payment-detail.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class OrderPaymentDetailComponent {
+    @Input() payment: OrderWithLines.Payments;
+    @Input() currencyCode: CurrencyCode;
+    @Output() settlePayment = new EventEmitter<OrderWithLines.Payments>();
+
+    getPaymentMetadata(payment: OrderWithLines.Payments) {
+        return Object.entries(payment.metadata);
+    }
+}

+ 4 - 0
admin-ui/src/app/order/components/payment-state-label/payment-state-label.component.html

@@ -0,0 +1,4 @@
+<vdr-chip [title]="'order.payment-state' | translate" [colorType]="chipColorType">
+    <clr-icon shape="check-circle" *ngIf="state === 'Settled'"></clr-icon>
+    {{ state }}
+</vdr-chip>

+ 3 - 0
admin-ui/src/app/order/components/payment-state-label/payment-state-label.component.scss

@@ -0,0 +1,3 @@
+:host {
+    font-size: 14px;
+}

+ 22 - 0
admin-ui/src/app/order/components/payment-state-label/payment-state-label.component.ts

@@ -0,0 +1,22 @@
+import { ChangeDetectionStrategy, Component, Input } from '@angular/core';
+
+@Component({
+    selector: 'vdr-payment-state-label',
+    templateUrl: './payment-state-label.component.html',
+    styleUrls: ['./payment-state-label.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class PaymentStateLabelComponent {
+    @Input() state: string;
+
+    get chipColorType() {
+        switch (this.state) {
+            case 'Authorized':
+                return 'warning';
+            case 'Settled':
+                return 'success';
+            case 'Declined':
+                return 'error';
+        }
+    }
+}

+ 114 - 0
admin-ui/src/app/order/components/refund-order-dialog/refund-order-dialog.component.html

@@ -0,0 +1,114 @@
+<ng-template vdrDialogTitle>{{ 'order.refund-order' | translate }}</ng-template>
+
+<div class="fulfillment-wrapper">
+    <div class="order-lines">
+        <table class="table">
+            <thead>
+                <tr>
+                    <th></th>
+                    <th>{{ 'order.product-name' | translate }}</th>
+                    <th>{{ 'order.product-sku' | translate }}</th>
+                    <th>{{ 'order.quantity' | translate }}</th>
+                    <th>{{ 'order.unit-price' | translate }}</th>
+                    <th>{{ 'order.refund' | translate }}</th>
+                    <th>{{ 'order.return-to-stock' | translate }}</th>
+                </tr>
+            </thead>
+            <tr *ngFor="let line of order.lines" class="order-line">
+                <td class="align-middle thumb">
+                    <img [src]="line.featuredAsset.preview + '?preset=tiny'" />
+                </td>
+                <td class="align-middle name">{{ line.productVariant.name }}</td>
+                <td class="align-middle sku">{{ line.productVariant.sku }}</td>
+                <td class="align-middle quantity">
+                    {{ line.quantity }}
+                    <vdr-line-refunds [line]="line"></vdr-line-refunds>
+                </td>
+                <td class="align-middle quantity">
+                    {{ line.unitPriceWithTax / 100 | currency: order.currencyCode }}
+                </td>
+                <td class="align-middle fulfil">
+                    <input
+                        *ngIf="lineCanBeRefunded(line)"
+                        [(ngModel)]="lineQuantities[line.id].quantity"
+                        type="number"
+                        [max]="line.quantity"
+                        min="0"
+                    />
+                </td>
+                <td class="align-middle">
+                    <div class="cancel-checkbox-wrapper">
+                        <input
+                            type="checkbox"
+                            *ngIf="lineCanBeRefunded(line)"
+                            clrCheckbox
+                            [disabled]="0 === lineQuantities[line.id].quantity"
+                            [(ngModel)]="lineQuantities[line.id].cancel"
+                        />
+                    </div>
+                </td>
+            </tr>
+        </table>
+    </div>
+    <div class="refund-details">
+        <label class="clr-control-label">{{ 'order.refund-reason' | translate }}</label>
+        <ng-select
+            [items]="reasons"
+            bindLabel="name"
+            autofocus
+            bindValue="id"
+            [addTag]="true"
+            [(ngModel)]="reason"
+        ></ng-select>
+
+        <clr-checkbox-wrapper>
+            <input type="checkbox" clrCheckbox [(ngModel)]="refundShipping" />
+            <label>
+                {{ 'order.refund-shipping' | translate }} ({{
+                    order.shipping / 100 | currency: order.currencyCode
+                }})
+            </label>
+        </clr-checkbox-wrapper>
+        <clr-input-container>
+            <label>{{ 'order.refund-adjustment' | translate }}</label>
+            <vdr-currency-input
+                clrInput
+                [currencyCode]="order.currencyCode"
+                [(ngModel)]="adjustment"
+            ></vdr-currency-input>
+        </clr-input-container>
+        <div class="totals">
+            <div class="order-total">
+                {{ 'order.order-total' | translate }}: {{ order.total / 100 | currency: order.currencyCode }}
+            </div>
+            <div class="refund-total">
+                {{ 'order.refund-total' | translate }}: {{ refundTotal / 100 | currency: order.currencyCode }}
+            </div>
+            <div class="refund-total-error" *ngIf="refundTotal < 0 || order.total < refundTotal">
+                {{
+                    'order.refund-total-error'
+                        | translate
+                            : {
+                                  min: 0 | currency: order.currencyCode,
+                                  max: order.total / 100 | currency: order.currencyCode
+                              }
+                }}
+            </div>
+        </div>
+    </div>
+</div>
+
+<ng-template vdrDialogButtons>
+    <button type="button" class="btn" (click)="cancel()">{{ 'common.cancel' | translate }}</button>
+    <button
+        type="submit"
+        (click)="select()"
+        [disabled]="!reason || refundTotal === 0 || refundTotal > order.total"
+        class="btn btn-primary"
+    >
+        {{
+            'order.refund-with-amount'
+                | translate: { amount: refundTotal / 100 | currency: order.currencyCode }
+        }}
+    </button>
+</ng-template>

+ 51 - 0
admin-ui/src/app/order/components/refund-order-dialog/refund-order-dialog.component.scss

@@ -0,0 +1,51 @@
+@import "variables";
+
+:host {
+    height: 100%;
+    display: flex;
+    min-height: 64vh;
+}
+.fulfillment-wrapper {
+    flex: 1;
+    @media screen and (min-width: $breakpoint-small) {
+        display: flex;
+        flex-direction: row;
+    }
+
+    .refund-details {
+        @media screen and (min-width: $breakpoint-small) {
+            margin-top: 0;
+            margin-left: 24px;
+            width: 250px;
+        }
+    }
+    .order-lines {
+        flex: 1;
+        overflow-y: auto;
+        table {
+            margin-top: 0;
+        }
+    }
+    tr.ignore {
+        color: $color-grey-300;
+    }
+}
+.cancel-checkbox-wrapper {
+    display: flex;
+    align-items: center;
+    justify-content: center;
+}
+clr-checkbox-wrapper {
+    margin-top: 12px;
+    margin-bottom: 12px;
+    display: block;
+}
+.totals {
+    margin-top: 48px;
+    .refund-total {
+        font-size: 18px;
+    }
+    .refund-total-error {
+        color: $color-error-500;
+    }
+}

+ 85 - 0
admin-ui/src/app/order/components/refund-order-dialog/refund-order-dialog.component.ts

@@ -0,0 +1,85 @@
+import { ChangeDetectionStrategy, Component, OnInit } from '@angular/core';
+import { _ } from 'src/app/core/providers/i18n/mark-for-extraction';
+
+import {
+    OrderLineInput,
+    OrderWithLines,
+    OrderWithLinesFragment,
+    RefundOrderInput,
+} from '../../../common/generated-types';
+import { I18nService } from '../../../core/providers/i18n/i18n.service';
+import { Dialog } from '../../../shared/providers/modal/modal.service';
+
+@Component({
+    selector: 'vdr-refund-order-dialog',
+    templateUrl: './refund-order-dialog.component.html',
+    styleUrls: ['./refund-order-dialog.component.scss'],
+    changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class RefundOrderDialogComponent
+    implements OnInit, Dialog<RefundOrderInput & { cancel: OrderLineInput[] }> {
+    order: OrderWithLinesFragment;
+    resolveWith: (result?: RefundOrderInput & { cancel: OrderLineInput[] }) => void;
+    reason: string;
+    lineQuantities: { [lineId: string]: { quantity: number; cancel: boolean } } = {};
+    refundShipping = false;
+    adjustment = 0;
+    reasons: string[] = [_('order.refund-reason-customer-request'), _('order.refund-reason-not-available')];
+
+    constructor(private i18nService: I18nService) {
+        this.reasons = this.reasons.map(r => this.i18nService.translate(r));
+    }
+
+    get refundTotal(): number {
+        const itemTotal = this.order.lines.reduce((total, line) => {
+            return total + line.unitPriceWithTax * (this.lineQuantities[line.id].quantity || 0);
+        }, 0);
+        return itemTotal + (this.refundShipping ? this.order.shipping : 0) + this.adjustment;
+    }
+
+    lineCanBeRefunded(line: OrderWithLines.Lines): boolean {
+        return 0 < line.items.filter(i => i.refundId == null && !i.cancelled).length;
+    }
+
+    ngOnInit() {
+        this.lineQuantities = this.order.lines.reduce((result, line) => {
+            return {
+                ...result,
+                [line.id]: {
+                    quantity: 0,
+                    cancel: false,
+                },
+            };
+        }, {});
+    }
+
+    select() {
+        const payment = this.order.payments && this.order.payments[0];
+        if (payment) {
+            const lines = Object.entries(this.lineQuantities)
+                .map(([orderLineId, data]) => ({
+                    orderLineId,
+                    quantity: data.quantity,
+                }))
+                .filter(l => 0 < l.quantity);
+            const cancel = Object.entries(this.lineQuantities)
+                .filter(([orderLineId, data]) => data.cancel)
+                .map(([orderLineId, data]) => ({
+                    orderLineId,
+                    quantity: data.quantity,
+                }));
+            this.resolveWith({
+                lines,
+                cancel,
+                reason: this.reason,
+                shipping: this.refundShipping ? this.order.shipping : 0,
+                adjustment: this.adjustment,
+                paymentId: payment.id,
+            });
+        }
+    }
+
+    cancel() {
+        this.resolveWith();
+    }
+}

+ 11 - 1
admin-ui/src/app/order/order.module.ts

@@ -3,10 +3,15 @@ import { RouterModule } from '@angular/router';
 
 import { SharedModule } from '../shared/shared.module';
 
+import { CancelOrderDialogComponent } from './components/cancel-order-dialog/cancel-order-dialog.component';
 import { FulfillOrderDialogComponent } from './components/fulfill-order-dialog/fulfill-order-dialog.component';
 import { LineFulfillmentComponent } from './components/line-fulfillment/line-fulfillment.component';
+import { LineRefundsComponent } from './components/line-refunds/line-refunds.component';
 import { OrderDetailComponent } from './components/order-detail/order-detail.component';
 import { OrderListComponent } from './components/order-list/order-list.component';
+import { OrderPaymentDetailComponent } from './components/order-payment-detail/order-payment-detail.component';
+import { PaymentStateLabelComponent } from './components/payment-state-label/payment-state-label.component';
+import { RefundOrderDialogComponent } from './components/refund-order-dialog/refund-order-dialog.component';
 import { orderRoutes } from './order.routes';
 import { OrderResolver } from './providers/routing/order-resolver';
 
@@ -17,8 +22,13 @@ import { OrderResolver } from './providers/routing/order-resolver';
         OrderDetailComponent,
         FulfillOrderDialogComponent,
         LineFulfillmentComponent,
+        RefundOrderDialogComponent,
+        CancelOrderDialogComponent,
+        PaymentStateLabelComponent,
+        LineRefundsComponent,
+        OrderPaymentDetailComponent,
     ],
-    entryComponents: [FulfillOrderDialogComponent],
+    entryComponents: [FulfillOrderDialogComponent, RefundOrderDialogComponent, CancelOrderDialogComponent],
     providers: [OrderResolver],
 })
 export class OrderModule {}

+ 22 - 0
admin-ui/src/i18n-messages/en.json

@@ -406,6 +406,12 @@
   },
   "order": {
     "amount": "Amount",
+    "cancel": "Cancel",
+    "cancel-order": "Cancel Order",
+    "cancel-reason-customer-request": "Customer request",
+    "cancel-reason-not-available": "Not available",
+    "cancellation-reason": "Cancellation reason",
+    "cancelled-order-success": "Successfully cancelled order",
     "create-fulfillment": "Create fulfillment",
     "create-fulfillment-success": "Created fulfillment",
     "customer": "Customer",
@@ -417,6 +423,7 @@
     "line-fulfillment-all": "All items fulfilled",
     "line-fulfillment-none": "No items fulfilled",
     "line-fulfillment-partial": "{ count } of { total } items fulfilled",
+    "order-total": "Order total",
     "payment": "Payment",
     "payment-metadata": "Payment metadata",
     "payment-method": "Payment method",
@@ -424,6 +431,21 @@
     "product-name": "Product name",
     "product-sku": "SKU",
     "quantity": "Quantity",
+    "refund": "Refund",
+    "refund-adjustment": "Adjustment",
+    "refund-and-cancel-order": "Refund & cancel order",
+    "refund-order": "Refund order",
+    "refund-order-success": "Successfully refunded order",
+    "refund-reason": "Refund reason",
+    "refund-reason-customer-request": "Customer request",
+    "refund-reason-not-available": "Not available",
+    "refund-shipping": "Refund shipping",
+    "refund-total": "Refund total",
+    "refund-total-error": "Refund total must be between {min} and {max}",
+    "refund-with-amount": "Refund {amount}",
+    "refunded-count": "{count} {count, plural, one {item} other {items}} refunded",
+    "refunds": "Refunds",
+    "return-to-stock": "Return to stock",
     "settle-payment": "Settle payment",
     "settle-payment-error": "Could not settle payment",
     "settle-payment-success": "Sucessfully settled payment",