diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml index 8b7363d2..024f86dc 100644 --- a/openapi/openapi.yaml +++ b/openapi/openapi.yaml @@ -42,6 +42,16 @@ components: $ref: paths/customer/payout-account/_types/payout-account.yaml CustomerPayoutAccountType: $ref: paths/customer/payout-account/_types/payout-account-type.yaml + CustomerPayoutAccountCreateBody: + $ref: paths/customer/payout-account/create/body.yaml + CustomerPayoutAccountCreateResponse: + $ref: paths/customer/payout-account/create/response.yaml + CustomerPayoutAccountDestroy: + $ref: paths/customer/payout-account/destroy/destroy.yaml + CustomerPayoutAccountDestroyResponse: + $ref: paths/customer/payout-account/destroy/response.yaml + CustomerPayoutAccountGetResponse: + $ref: paths/customer/payout-account/get/response.yaml CustomerPayoutMobilePay: $ref: paths/customer/payout-account/_types/payout-account-mobile-pay.yaml CustomerPayoutBankAccount: @@ -160,6 +170,22 @@ components: CustomerBlockedRangeResponse: $ref: paths/customer/blocked/range/response.yaml + # Payout + CustomerPayout: + $ref: paths/customer/payout/_types/payout.yaml + CustomerPayoutBalancePayload: + $ref: paths/customer/payout/balance/payload.yaml + CustomerPayoutBalanceResponse: + $ref: paths/customer/payout/balance/response.yaml + CustomerPayoutGetResponse: + $ref: paths/customer/payout/get/response.yaml + CustomerPayoutPaginateResponse: + $ref: paths/customer/payout/paginate/response.yaml + + # Payout Log + CustomerPayoutLogResponse: + $ref: paths/customer/payout-log/paginate/response.yaml + # Booking CustomerBooking: $ref: paths/customer/booking/_types/booking.yaml @@ -347,6 +373,18 @@ paths: /customer/{customerId}/orders/{orderId}: $ref: "paths/customer/order/get/index.yaml" + # Customer Payout Management + /customer/{customerId}/payout: + $ref: "./paths/customer/payout/paginate/index.yaml" + /customer/{customerId}/payout/balance: + $ref: "./paths/customer/payout/balance/index.yaml" + /customer/{customerId}/payout/{payoutId}: + $ref: "./paths/customer/payout/get/index.yaml" + + # Customer Payout Log Management + /customer/{customerId}/payout-log/{payoutId}: + $ref: "./paths/customer/payout-log/paginate/index.yaml" + # Customer Payout Account Management /customer/{customerId}/payout-account: $ref: "./paths/customer/payout-account/payout-account.yaml" diff --git a/openapi/paths/customer/payout-account/get/response.yaml b/openapi/paths/customer/payout-account/get/response.yaml index 431e6f3f..6ff4f31c 100644 --- a/openapi/paths/customer/payout-account/get/response.yaml +++ b/openapi/paths/customer/payout-account/get/response.yaml @@ -6,7 +6,7 @@ properties: payload: allOf: - $ref: "../_types/payout-account.yaml" - nullable: true + nullable: true required: - success - payload diff --git a/openapi/paths/customer/payout-log/paginate/index.yaml b/openapi/paths/customer/payout-log/paginate/index.yaml new file mode 100644 index 00000000..d60e42c3 --- /dev/null +++ b/openapi/paths/customer/payout-log/paginate/index.yaml @@ -0,0 +1,53 @@ +get: + parameters: + - name: customerId + in: path + description: The ID of the customerId + required: true + schema: + type: string + - name: payoutId + in: path + description: The ID of the payoutId + required: true + schema: + type: string + - name: page + in: query + description: The page number + required: true + schema: + type: string + - name: sortOrder + in: query + description: The sort order either asc eller desc = default desc + schema: + type: string + - name: limit + in: query + description: The limit = default to 10 + schema: + type: string + tags: + - CustomerPayoutLog + operationId: customerPayoutLogPaginate + summary: GET get all payout logs for specific payout using paginate + description: This endpoint get all payout logs for specific payout + + responses: + "200": + description: Response with payouts payload + content: + application/json: + schema: + $ref: "./response.yaml" + "400": + $ref: "../../../../responses/bad.yaml" + "401": + $ref: "../../../../responses/unauthorized.yaml" + "403": + $ref: "../../../../responses/forbidden.yaml" + "404": + $ref: "../../../../responses/not-found.yaml" + + security: [] diff --git a/openapi/paths/customer/payout-log/paginate/response.yaml b/openapi/paths/customer/payout-log/paginate/response.yaml new file mode 100644 index 00000000..354f1e73 --- /dev/null +++ b/openapi/paths/customer/payout-log/paginate/response.yaml @@ -0,0 +1,14 @@ +type: object +properties: + success: + type: boolean + example: true + payload: + type: array + items: + oneOf: + - $ref: "../../_types/order/line-item.yaml" + - $ref: ../../../shipping/_types/shipping.yaml +required: + - success + - payload diff --git a/openapi/paths/customer/payout/_types/payout.yaml b/openapi/paths/customer/payout/_types/payout.yaml new file mode 100644 index 00000000..787dfb44 --- /dev/null +++ b/openapi/paths/customer/payout/_types/payout.yaml @@ -0,0 +1,31 @@ +type: object +properties: + customerId: + type: number + date: + type: string + format: date-time + amount: + type: number + format: double + currencyCode: + type: string + example: "DKK" + status: + type: string + enum: + - "Pending" + - "Processed" + - "Failed" + payoutType: + $ref: ../../payout-account/_types/payout-account-type.yaml + payoutDetails: + oneOf: + - $ref: ../../payout-account/_types/payout-account-mobile-pay.yaml + - $ref: ../../payout-account/_types/payout-account-bank-account.yaml +required: + - customerId + - date + - amount + - currencyCode + - status diff --git a/openapi/paths/customer/payout/balance/index.yaml b/openapi/paths/customer/payout/balance/index.yaml new file mode 100644 index 00000000..5d217667 --- /dev/null +++ b/openapi/paths/customer/payout/balance/index.yaml @@ -0,0 +1,31 @@ +get: + parameters: + - name: customerId + in: path + description: The ID of the customerId + required: true + schema: + type: string + tags: + - CustomerPayout + operationId: customerPayoutBalance + summary: GET get payout balance + description: This endpoint get payout balance + + responses: + "200": + description: Response with payout balance payload + content: + application/json: + schema: + $ref: "./response.yaml" + "400": + $ref: "../../../../responses/bad.yaml" + "401": + $ref: "../../../../responses/unauthorized.yaml" + "403": + $ref: "../../../../responses/forbidden.yaml" + "404": + $ref: "../../../../responses/not-found.yaml" + + security: [] diff --git a/openapi/paths/customer/payout/balance/payload.yaml b/openapi/paths/customer/payout/balance/payload.yaml new file mode 100644 index 00000000..dc4ae0fd --- /dev/null +++ b/openapi/paths/customer/payout/balance/payload.yaml @@ -0,0 +1,12 @@ +type: object +properties: + totalAmount: + type: number + totalLineItems: + type: number + totalShippingAmount: + type: number +required: + - totalAmount + - totalLineItems + - totalShippingAmount diff --git a/openapi/paths/customer/payout/balance/response.yaml b/openapi/paths/customer/payout/balance/response.yaml new file mode 100644 index 00000000..74416dd7 --- /dev/null +++ b/openapi/paths/customer/payout/balance/response.yaml @@ -0,0 +1,10 @@ +type: object +properties: + success: + type: boolean + example: true + payload: + $ref: payload.yaml +required: + - success + - payload diff --git a/openapi/paths/customer/payout/get/index.yaml b/openapi/paths/customer/payout/get/index.yaml new file mode 100644 index 00000000..d2fc7b3d --- /dev/null +++ b/openapi/paths/customer/payout/get/index.yaml @@ -0,0 +1,37 @@ +get: + parameters: + - name: customerId + in: path + description: The ID of the customerId + required: true + schema: + type: string + - name: payoutId + in: path + description: The ID of the payoudId + required: true + schema: + type: string + tags: + - CustomerPayout + operationId: customerPayoutGet + summary: GET get payout + description: This endpoint get payout + + responses: + "200": + description: Response with payout payload + content: + application/json: + schema: + $ref: "./response.yaml" + "400": + $ref: "../../../../responses/bad.yaml" + "401": + $ref: "../../../../responses/unauthorized.yaml" + "403": + $ref: "../../../../responses/forbidden.yaml" + "404": + $ref: "../../../../responses/not-found.yaml" + + security: [] diff --git a/openapi/paths/customer/payout/get/response.yaml b/openapi/paths/customer/payout/get/response.yaml new file mode 100644 index 00000000..7c60da94 --- /dev/null +++ b/openapi/paths/customer/payout/get/response.yaml @@ -0,0 +1,10 @@ +type: object +properties: + success: + type: boolean + example: true + payload: + $ref: "../_types/payout.yaml" +required: + - success + - payload diff --git a/openapi/paths/customer/payout/paginate/index.yaml b/openapi/paths/customer/payout/paginate/index.yaml new file mode 100644 index 00000000..7eb7f9a7 --- /dev/null +++ b/openapi/paths/customer/payout/paginate/index.yaml @@ -0,0 +1,47 @@ +get: + parameters: + - name: customerId + in: path + description: The ID of the customerId + required: true + schema: + type: string + - name: page + in: query + description: The page number + required: true + schema: + type: string + - name: sortOrder + in: query + description: The sort order either asc eller desc = default desc + schema: + type: string + - name: limit + in: query + description: The limit = default to 10 + schema: + type: string + tags: + - CustomerPayout + operationId: customerPayoutPaginate + summary: GET get all payouts using paginate + description: This endpoint get all payouts + + responses: + "200": + description: Response with payouts payload + content: + application/json: + schema: + $ref: "./response.yaml" + "400": + $ref: "../../../../responses/bad.yaml" + "401": + $ref: "../../../../responses/unauthorized.yaml" + "403": + $ref: "../../../../responses/forbidden.yaml" + "404": + $ref: "../../../../responses/not-found.yaml" + + security: [] diff --git a/openapi/paths/customer/payout/paginate/response.yaml b/openapi/paths/customer/payout/paginate/response.yaml new file mode 100644 index 00000000..ccbce222 --- /dev/null +++ b/openapi/paths/customer/payout/paginate/response.yaml @@ -0,0 +1,12 @@ +type: object +properties: + success: + type: boolean + example: true + payload: + type: array + items: + $ref: ../_types/payout.yaml +required: + - success + - payload diff --git a/openapi/paths/user/list/response.yaml b/openapi/paths/user/list/response.yaml index d6d9e758..28594a11 100644 --- a/openapi/paths/user/list/response.yaml +++ b/openapi/paths/user/list/response.yaml @@ -12,7 +12,7 @@ properties: type: array items: $ref: "../_types/user.yaml" - total: + totalCount: type: number required: - results diff --git a/src/functions/customer-payout-log.function.ts b/src/functions/customer-payout-log.function.ts new file mode 100644 index 00000000..34a5f8f7 --- /dev/null +++ b/src/functions/customer-payout-log.function.ts @@ -0,0 +1,12 @@ +import "module-alias/register"; + +import { app } from "@azure/functions"; + +import { CustomerPayoutLogControllerPaginate } from "./customer/controllers/payout-log/paginate"; + +app.http("customerPayoutLogPaginate", { + methods: ["GET"], + authLevel: "anonymous", + route: "customer/{customerId?}/payout-log/{payoutId}", + handler: CustomerPayoutLogControllerPaginate, +}); diff --git a/src/functions/customer-payout.functions.ts b/src/functions/customer-payout.functions.ts new file mode 100644 index 00000000..8fa62a58 --- /dev/null +++ b/src/functions/customer-payout.functions.ts @@ -0,0 +1,28 @@ +import "module-alias/register"; + +import { app } from "@azure/functions"; + +import { CustomerPayoutControllerBalance } from "./customer/controllers/payout/balance"; +import { CustomerPayoutControllerGet } from "./customer/controllers/payout/get"; +import { CustomerPayoutControllerPaginate } from "./customer/controllers/payout/paginate"; + +app.http("customerPayoutGet", { + methods: ["GET"], + authLevel: "anonymous", + route: "customer/{customerId?}/payout/{payoutId?}", + handler: CustomerPayoutControllerGet, +}); + +app.http("customerPayoutBalance", { + methods: ["GET"], + authLevel: "anonymous", + route: "customer/{customerId?}/payout/balance", + handler: CustomerPayoutControllerBalance, +}); + +app.http("customerPayoutPaginate", { + methods: ["GET"], + authLevel: "anonymous", + route: "customer/{customerId?}/payout", + handler: CustomerPayoutControllerPaginate, +}); diff --git a/src/functions/customer/controllers/payout-log/paginate.ts b/src/functions/customer/controllers/payout-log/paginate.ts new file mode 100644 index 00000000..2253d91f --- /dev/null +++ b/src/functions/customer/controllers/payout-log/paginate.ts @@ -0,0 +1,33 @@ +import { _ } from "~/library/handler"; + +import { z } from "zod"; +import { NumberOrStringType, ObjectIdType } from "~/library/zod"; +import { CustomerPayoutLogServicePaginate } from "../../services/payout-log/paginate"; + +export type CustomerPayoutLogControllerPaginateRequest = { + query: z.infer; +}; + +enum SortOrder { + ASC = "asc", + DESC = "desc", +} + +export const CustomerPayoutLogControllerPaginateSchema = z.object({ + page: NumberOrStringType, + limit: NumberOrStringType.optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + customerId: NumberOrStringType, + payoutId: ObjectIdType, +}); + +export type CustomerPayoutLogControllerPaginateResponse = Awaited< + ReturnType +>; + +export const CustomerPayoutLogControllerPaginate = _( + async ({ query }: CustomerPayoutLogControllerPaginateRequest) => { + const validateData = CustomerPayoutLogControllerPaginateSchema.parse(query); + return CustomerPayoutLogServicePaginate(validateData); + } +); diff --git a/src/functions/customer/controllers/payout/balance.ts b/src/functions/customer/controllers/payout/balance.ts new file mode 100644 index 00000000..5901a592 --- /dev/null +++ b/src/functions/customer/controllers/payout/balance.ts @@ -0,0 +1,23 @@ +import { z } from "zod"; +import { _ } from "~/library/handler"; +import { NumberOrStringType } from "~/library/zod"; +import { CustomerPayoutServiceBalance } from "../../services/payout/balance"; + +export type CustomerPayoutControllerBalanceRequest = { + query: z.infer; +}; + +export const CustomerPayoutControllerBalanceSchema = z.object({ + customerId: NumberOrStringType, +}); + +export type CustomerPayoutControllerBalanceResponse = Awaited< + ReturnType +>; + +export const CustomerPayoutControllerBalance = _( + ({ query }: CustomerPayoutControllerBalanceRequest) => { + const validateQuery = CustomerPayoutControllerBalanceSchema.parse(query); + return CustomerPayoutServiceBalance(validateQuery); + } +); diff --git a/src/functions/customer/controllers/payout/get.ts b/src/functions/customer/controllers/payout/get.ts new file mode 100644 index 00000000..bb9e51da --- /dev/null +++ b/src/functions/customer/controllers/payout/get.ts @@ -0,0 +1,24 @@ +import { z } from "zod"; +import { _ } from "~/library/handler"; +import { NumberOrStringType, ObjectIdType } from "~/library/zod"; +import { CustomerPayoutServiceGet } from "../../services/payout/get"; + +export type CustomerPayoutControllerGetRequest = { + query: z.infer; +}; + +export const CustomerPayoutControllerGetSchema = z.object({ + customerId: NumberOrStringType, + payoutId: ObjectIdType, +}); + +export type CustomerPayoutControllerGetResponse = Awaited< + ReturnType +>; + +export const CustomerPayoutControllerGet = _( + ({ query }: CustomerPayoutControllerGetRequest) => { + const validateQuery = CustomerPayoutControllerGetSchema.parse(query); + return CustomerPayoutServiceGet(validateQuery); + } +); diff --git a/src/functions/customer/controllers/payout/paginate.ts b/src/functions/customer/controllers/payout/paginate.ts new file mode 100644 index 00000000..8382b3fe --- /dev/null +++ b/src/functions/customer/controllers/payout/paginate.ts @@ -0,0 +1,32 @@ +import { _ } from "~/library/handler"; + +import { z } from "zod"; +import { NumberOrStringType } from "~/library/zod"; +import { CustomerPayoutServicePaginate } from "../../services/payout/paginate"; + +export type CustomerPayoutControllerPaginateRequest = { + query: z.infer; +}; + +enum SortOrder { + ASC = "asc", + DESC = "desc", +} + +export const CustomerPayoutControllerPaginateSchema = z.object({ + page: NumberOrStringType, + limit: NumberOrStringType.optional(), + sortOrder: z.nativeEnum(SortOrder).optional(), + customerId: NumberOrStringType, +}); + +export type CustomerPayoutControllerPaginateResponse = Awaited< + ReturnType +>; + +export const CustomerPayoutControllerPaginate = _( + async ({ query }: CustomerPayoutControllerPaginateRequest) => { + const validateData = CustomerPayoutControllerPaginateSchema.parse(query); + return CustomerPayoutServicePaginate(validateData); + } +); diff --git a/src/functions/customer/services/blocked/list.ts b/src/functions/customer/services/blocked/list.ts index c3414103..f596cd19 100644 --- a/src/functions/customer/services/blocked/list.ts +++ b/src/functions/customer/services/blocked/list.ts @@ -32,27 +32,22 @@ export const CustomerBlockedServiceList = async ({ }; const limitStage = { - $limit: limit, + $limit: limit + 1, }; - // Query to get the documents - const results = await BlockedModel.aggregate< + const blocked = await BlockedModel.aggregate< Blocked & { _id: StringOrObjectId } - >([ - matchStage, - { $sort: { _id: 1 } }, // Ensure results are sorted for consistent pagination - limitStage, - ]); + >([matchStage, { $sort: { _id: 1 } }, limitStage]); const totalCount = await BlockedModel.countDocuments({ customerId }); - - // Calculate nextCursor based on the last document in the results, if any - const newNextCursor = - results.length > 0 ? results[results.length - 1]._id.toString() : undefined; + const hasNextPage = blocked.length > limit; + const results = hasNextPage ? blocked.slice(0, -1) : blocked; return { results, + nextCursor: hasNextPage + ? results[results.length - 1]._id.toString() + : undefined, totalCount, - nextCursor: newNextCursor, }; }; diff --git a/src/functions/customer/services/payout-log/paginate.spec.ts b/src/functions/customer/services/payout-log/paginate.spec.ts new file mode 100644 index 00000000..7f0c96b6 --- /dev/null +++ b/src/functions/customer/services/payout-log/paginate.spec.ts @@ -0,0 +1,57 @@ +import { OrderModel } from "~/functions/order/order.models"; +import { Order } from "~/functions/order/order.types"; + +import { faker } from "@faker-js/faker"; +import { createPayoutAccount } from "~/library/jest/helpers/payout-account"; +import { createShipping } from "~/library/jest/helpers/shipping"; + +import { CustomerPayoutServiceCreate } from "../payout/create"; +import { dummyDataBalance } from "../payout/fixtures/dummydata.balance"; +import { CustomerPayoutLogServicePaginate } from "./paginate"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerServicePayoutLogList", () => { + const customerId = 7106990342471; + + beforeEach(async () => { + const shipping = await createShipping({ origin: { customerId } }); + dummyDataBalance.id = faker.number.int({ min: 1000000, max: 100000000 }); + dummyDataBalance.line_items = dummyDataBalance.line_items.map( + (lineItem) => { + lineItem.current_quantity = 1; + lineItem.fulfillable_quantity = 0; + lineItem.fulfillment_status = "fulfilled"; + const price = faker.number.float({ + min: 100, + max: 500, + }); + lineItem.price = price.toFixed(2); + lineItem.properties = [ + { name: "_from", value: "2023-12-14T08:12:00.000Z" }, + { name: "_to", value: "2023-12-14T09:27:00.000Z" }, + { name: "_customerId", value: customerId }, + { name: "_locationId", value: "64a6c1bde5df64bb85935732" }, + { name: "_shippingId", value: shipping._id.toString() }, + ] as any; + lineItem.id = faker.number.int({ min: 1000000, max: 100000000 }); + return lineItem; + } + ); + + await createPayoutAccount({ customerId }); + await OrderModel.create(Order.parse(dummyDataBalance)); + }); + + it("should get payout with line-items", async () => { + const payout = await CustomerPayoutServiceCreate({ customerId }); + + const response = await CustomerPayoutLogServicePaginate({ + page: 1, + customerId, + payoutId: payout._id, + }); + + expect(response.results.length).toBe(3); + }); +}); diff --git a/src/functions/customer/services/payout-log/paginate.ts b/src/functions/customer/services/payout-log/paginate.ts new file mode 100644 index 00000000..145159eb --- /dev/null +++ b/src/functions/customer/services/payout-log/paginate.ts @@ -0,0 +1,119 @@ +import { FilterQuery } from "mongoose"; +import { OrderModel } from "~/functions/order/order.models"; +import { OrderLineItem } from "~/functions/order/order.types"; +import { + IPayoutLogDocument, + PayoutLog, + PayoutLogModel, + PayoutLogReferenceType, +} from "~/functions/payout-log"; +import { ShippingModel } from "~/functions/shipping/shipping.model"; +import { Shipping } from "~/functions/shipping/shipping.types"; +import { StringOrObjectId } from "~/library/zod"; + +export type CustomerPayoutLogServicePaginateProps = { + page: number; + customerId: number; + payoutId: StringOrObjectId; + limit?: number; + sortOrder?: "asc" | "desc"; +}; + +export const CustomerPayoutLogServicePaginate = async ({ + page = 1, + limit = 10, + sortOrder = "desc", + customerId, + payoutId, +}: CustomerPayoutLogServicePaginateProps) => { + let query: FilterQuery = { + customerId, + payout: payoutId, + }; + + const sortParam = sortOrder === "asc" ? 1 : -1; + const skipDocuments = (page - 1) * limit; + + const totalCount = await PayoutLogModel.countDocuments(query); + const totalPages = Math.ceil(totalCount / limit); + + const logs = await PayoutLogModel.aggregate< + PayoutLog & { referenceDocument: OrderLineItem | Shipping } + >([ + { $match: query }, + { $sort: { createdAt: sortParam } }, + { $skip: skipDocuments }, + { $limit: limit }, + { + $facet: { + LineItem: [ + { $match: { referenceType: PayoutLogReferenceType.LINE_ITEM } }, + { + $lookup: { + from: OrderModel.collection.name, + let: { lineItemId: "$referenceId" }, + pipeline: [ + { + $match: { + "line_items.properties.customerId": customerId, + }, + }, + { $unwind: "$line_items" }, + { + $match: { + $expr: { $eq: ["$line_items.id", "$$lineItemId"] }, + }, + }, + { $project: { line_item: "$line_items", _id: 0 } }, + { $limit: 1 }, + ], + as: "referenceDocument", + }, + }, + ], + Shipping: [ + { $match: { referenceType: PayoutLogReferenceType.SHIPPING } }, + { + $lookup: { + from: ShippingModel.collection.name, + localField: "referenceId", + foreignField: "_id", + as: "referenceDocument", + }, + }, + ], + }, + }, + { + $project: { + results: { $setUnion: ["$LineItem", "$Shipping"] }, // Combine these arrays back into a single array + }, + }, + { $unwind: "$results" }, + { $replaceRoot: { newRoot: "$results" } }, + { + $unwind: { + path: "$referenceDocument", + preserveNullAndEmptyArrays: true, + }, + }, + { + $addFields: { + referenceDocument: { + $cond: { + if: { $eq: ["$referenceType", PayoutLogReferenceType.LINE_ITEM] }, + then: "$referenceDocument.line_item", + else: "$referenceDocument", + }, + }, + }, + }, + ]); + + return { + results: logs, + currentPage: page, + totalPages, + totalCount, + }; +}; diff --git a/src/functions/customer/services/payout/aggregation.ts b/src/functions/customer/services/payout/aggregation.ts new file mode 100644 index 00000000..4c705a6f --- /dev/null +++ b/src/functions/customer/services/payout/aggregation.ts @@ -0,0 +1,115 @@ +import { PipelineStage } from "mongoose"; +import { PayoutLogModel, PayoutLogReferenceType } from "~/functions/payout-log"; +import { ShippingModel } from "~/functions/shipping/shipping.model"; + +export const lineItemAggregation = ({ customerId }: { customerId: number }) => [ + { + $match: { + "line_items.properties.customerId": customerId, + "line_items.current_quantity": 1, + "line_items.fulfillable_quantity": 0, + "line_items.fulfillment_status": "fulfilled", + }, + }, + { $unwind: "$line_items" }, + { + $match: { + "line_items.properties.customerId": customerId, + "line_items.current_quantity": 1, + "line_items.fulfillable_quantity": 0, + "line_items.fulfillment_status": "fulfilled", + }, + }, + { + $lookup: { + from: PayoutLogModel.collection.name, + let: { + referenceId: "$line_items.id", + customerId: "$line_items.properties.customerId", + }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$referenceId", "$$referenceId"] }, + { $eq: ["$customerId", "$$customerId"] }, + { $eq: ["$referenceType", PayoutLogReferenceType.LINE_ITEM] }, + ], + }, + }, + }, + ], + as: "payoutLog", + }, + }, + { + $match: { + payoutLog: { $size: 0 }, + }, + }, +]; + +export const shippingAggregation: PipelineStage[] = [ + { + $lookup: { + from: ShippingModel.collection.name, + let: { shippingId: "$line_items.properties.shippingId" }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { + $eq: ["$_id", { $toObjectId: "$$shippingId" }], + }, + ], + }, + }, + }, + { + $project: { + cost: 1, + }, + }, + ], + as: "shipping", + }, + }, + { $unwind: "$shipping" }, + { + $lookup: { + from: PayoutLogModel.collection.name, + let: { + shippingId: { $toObjectId: "$line_items.properties.shippingId" }, + }, + pipeline: [ + { + $match: { + $expr: { + $and: [ + { $eq: ["$referenceId", "$$shippingId"] }, + { $eq: ["$referenceType", PayoutLogReferenceType.SHIPPING] }, + ], + }, + }, + }, + ], + as: "shippingPayoutLog", + }, + }, + { + $addFields: { + "shipping.cost.value": { + $cond: { + if: { $gt: [{ $size: "$shippingPayoutLog" }, 0] }, // Check if shipping has been paid + then: 0, // If paid, set shipping cost value to 0 + else: { + // If not paid, use the existing shipping cost value + $ifNull: ["$shipping.cost.value", 0], + }, + }, + }, + }, + }, +]; diff --git a/src/functions/customer/services/payout/balance.spec.ts b/src/functions/customer/services/payout/balance.spec.ts index ce7fee28..c6bdcc84 100644 --- a/src/functions/customer/services/payout/balance.spec.ts +++ b/src/functions/customer/services/payout/balance.spec.ts @@ -2,16 +2,41 @@ import { OrderModel } from "~/functions/order/order.models"; import { Order } from "~/functions/order/order.types"; import mongoose from "mongoose"; -import { PayoutLogModel } from "~/functions/payout-log"; +import { PayoutLogModel, PayoutLogReferenceType } from "~/functions/payout-log"; +import { createShipping } from "~/library/jest/helpers/shipping"; import { CustomerPayoutServiceBalance } from "./balance"; import { dummyDataBalance } from "./fixtures/dummydata.balance"; require("~/library/jest/mongoose/mongodb.jest"); -describe("CustomerOrderServiceGet", () => { +describe("CustomerPayoutServiceBalance", () => { const customerId = 7106990342471; beforeEach(async () => { + dummyDataBalance.line_items = await Promise.all( + dummyDataBalance.line_items.map(async (lineItem) => { + const shipping = await createShipping({ origin: { customerId } }); + lineItem.properties = [ + { name: "_from", value: "2023-12-14T08:12:00.000Z" }, + { name: "_to", value: "2023-12-14T09:27:00.000Z" }, + { name: "_customerId", value: customerId }, + { name: "_locationId", value: "64a6c1bde5df64bb85935732" }, + { name: "_shippingId", value: shipping._id.toString() }, + ] as any; + return lineItem; + }) + ); + }); + + it("should return balance for all line-items", async () => { + const dumbData = Order.parse(dummyDataBalance); + await OrderModel.create(dumbData); + const response = await CustomerPayoutServiceBalance({ customerId }); + expect(response.totalAmount).toBeGreaterThan(180); + }); + + it("should not repay shipping if already paid out", async () => { + const shipping = await createShipping({ origin: { customerId } }); dummyDataBalance.line_items = dummyDataBalance.line_items.map( (lineItem) => { lineItem.properties = [ @@ -19,21 +44,23 @@ describe("CustomerOrderServiceGet", () => { { name: "_to", value: "2023-12-14T09:27:00.000Z" }, { name: "_customerId", value: customerId }, { name: "_locationId", value: "64a6c1bde5df64bb85935732" }, - { name: "_freeShipping", value: "true" }, - { name: "Skønhedsekspert", value: "hana nielsen" }, - { name: "Tid", value: "torsdag, 14. december 11:12" }, - { name: "Varighed", value: "1 time(r)" }, - { name: "_shippingId", value: "6577aa3e90f051e536292f3c" }, + { name: "_shippingId", value: shipping._id.toString() }, ] as any; return lineItem; } ); - }); - it("should return balance for all line-items", async () => { + + await PayoutLogModel.create({ + customerId, + referenceId: shipping._id, + referenceType: PayoutLogReferenceType.SHIPPING, + payout: new mongoose.Types.ObjectId(), + }); + const dumbData = Order.parse(dummyDataBalance); await OrderModel.create(dumbData); const response = await CustomerPayoutServiceBalance({ customerId }); - expect(response).toBe(180); + expect(response.totalAmount).toBe(180); }); it("should exclude balance that have been paid out", async () => { @@ -42,12 +69,13 @@ describe("CustomerOrderServiceGet", () => { await PayoutLogModel.create({ customerId, - lineItemId: dumbData.line_items[0].id, + referenceId: dumbData.line_items[0].id, + referenceType: PayoutLogReferenceType.LINE_ITEM, payout: new mongoose.Types.ObjectId(), }); const response = await CustomerPayoutServiceBalance({ customerId }); - expect(response).toBe(0); + expect(response.totalAmount).toBe(0); }); it("should calculate all items that are fulfilled and not yet paid out", async () => { @@ -63,16 +91,17 @@ describe("CustomerOrderServiceGet", () => { await PayoutLogModel.create({ customerId, - lineItemId: dumbData.line_items[0].id, + referenceId: dumbData.line_items[0].id, + referenceType: PayoutLogReferenceType.LINE_ITEM, payout: new mongoose.Types.ObjectId(), }); const response = await CustomerPayoutServiceBalance({ customerId }); - expect(response).toBe(300); + expect(response.totalAmount).toBeGreaterThan(300); }); it("should return zero balance when orders is empty", async () => { const response = await CustomerPayoutServiceBalance({ customerId: 0 }); - expect(response).toBe(0); + expect(response.totalAmount).toBe(0); }); }); diff --git a/src/functions/customer/services/payout/balance.ts b/src/functions/customer/services/payout/balance.ts index b0ccd506..28c7e04d 100644 --- a/src/functions/customer/services/payout/balance.ts +++ b/src/functions/customer/services/payout/balance.ts @@ -1,4 +1,4 @@ -import { OrderModel } from "~/functions/order/order.models"; +import { CustomerPayoutServiceGetLineItemsFulfilled } from "./create"; export type CustomerPayoutServiceBalanceProps = { customerId: number; @@ -7,59 +7,33 @@ export type CustomerPayoutServiceBalanceProps = { export const CustomerPayoutServiceBalance = async ({ customerId, }: CustomerPayoutServiceBalanceProps) => { - const aggregationResult = await OrderModel.aggregate([ - { - $match: { - "line_items.properties.customerId": customerId, - }, - }, - { $unwind: "$line_items" }, - { - $lookup: { - from: "PayoutLog", - let: { - lineItemId: "$line_items.id", - customerId: "$line_items.properties.customerId", - }, - pipeline: [ - { - $match: { - $expr: { - $and: [ - { $eq: ["$lineItemId", "$$lineItemId"] }, - { $eq: ["$customerId", "$$customerId"] }, - ], - }, - }, - }, - ], - as: "payoutLog", - }, - }, - { - $match: { - "line_items.properties.customerId": customerId, - "line_items.current_quantity": 1, - "line_items.fulfillable_quantity": 0, - "line_items.fulfillment_status": "fulfilled", - }, - }, - { - $match: { - payoutLog: { $size: 0 }, - }, - }, - { - $group: { - _id: null, - totalBalancePrice: { $sum: { $toDouble: "$line_items.price" } }, - }, - }, - ]); + const lineItems = await CustomerPayoutServiceGetLineItemsFulfilled({ + customerId, + }); - if (aggregationResult.length === 0) { - return 0; // No matching documents, so return 0 - } else { - return aggregationResult[0].totalBalancePrice; // Return the calculated total - } + const totalLineItems = lineItems.reduce( + (accumulator, { line_items }) => accumulator + parseFloat(line_items.price), + 0 + ); + + const shippings = lineItems + .filter((lineItem) => lineItem.shipping) + .map(({ shipping }) => shipping); + + let uniqueShippings = Array.from( + new Map( + shippings.map((shipping) => [shipping._id.toString(), shipping]) + ).values() + ); + + const totalShippingAmount = uniqueShippings.reduce( + (accumulator, { cost }) => accumulator + cost.value, + 0 + ); + + return { + totalAmount: totalLineItems + totalShippingAmount, + totalLineItems, + totalShippingAmount, + }; }; diff --git a/src/functions/customer/services/payout/create.spec.ts b/src/functions/customer/services/payout/create.spec.ts new file mode 100644 index 00000000..3466638f --- /dev/null +++ b/src/functions/customer/services/payout/create.spec.ts @@ -0,0 +1,87 @@ +import { OrderModel } from "~/functions/order/order.models"; +import { Order } from "~/functions/order/order.types"; + +import { faker } from "@faker-js/faker"; +import { PayoutLogModel, PayoutLogReferenceType } from "~/functions/payout-log"; +import { IShippingDocument } from "~/functions/shipping/shipping.schema"; +import { createPayoutAccount } from "~/library/jest/helpers/payout-account"; +import { createShipping } from "~/library/jest/helpers/shipping"; +import { + CustomerPayoutServiceCreate, + CustomerPayoutServiceGetLineItemsFulfilled, +} from "./create"; +import { dummyDataBalance } from "./fixtures/dummydata.balance"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerPayoutServiceCreate", () => { + const customerId = 7106990342471; + let shipping: IShippingDocument; + + beforeEach(async () => { + shipping = await createShipping({ origin: { customerId } }); + dummyDataBalance.id = faker.number.int({ min: 1000000, max: 100000000 }); + dummyDataBalance.line_items = dummyDataBalance.line_items.map( + (lineItem) => { + lineItem.current_quantity = 1; + lineItem.fulfillable_quantity = 0; + lineItem.fulfillment_status = "fulfilled"; + const price = faker.number.float({ + min: 100, + max: 500, + }); + lineItem.price = price.toFixed(2); + lineItem.properties = [ + { name: "_from", value: "2023-12-14T08:12:00.000Z" }, + { name: "_to", value: "2023-12-14T09:27:00.000Z" }, + { name: "_customerId", value: customerId }, + { name: "_locationId", value: "64a6c1bde5df64bb85935732" }, + { name: "_shippingId", value: shipping._id.toString() }, + ] as any; + lineItem.id = faker.number.int({ min: 1000000, max: 100000000 }); + return lineItem; + } + ); + + await createPayoutAccount({ customerId }); + await OrderModel.create(Order.parse(dummyDataBalance)); + }); + + it("should create payout and payoutlog", async () => { + const lineItems = await CustomerPayoutServiceGetLineItemsFulfilled({ + customerId, + }); + + const totalAmount = lineItems.reduce( + (accumulator, { line_items }) => + accumulator + parseFloat(line_items.price), + 0 + ); + + const response = await CustomerPayoutServiceCreate({ customerId }); + expect(response.amount).toBe(totalAmount + shipping.cost.value); + + const payoutLogs = await PayoutLogModel.find({ payout: response._id }); + + const lineItemIds = lineItems.map(({ line_items }) => line_items.id); + const everyLineItemHasPayoutLog = lineItemIds.every((lineItemId) => + payoutLogs.some((payoutLog) => payoutLog.referenceId === lineItemId) + ); + expect(everyLineItemHasPayoutLog).toBe(true); + + const shippingLogCount = await PayoutLogModel.countDocuments({ + referenceType: PayoutLogReferenceType.SHIPPING, + }); + const lineItemLogCount = await PayoutLogModel.countDocuments({ + referenceType: PayoutLogReferenceType.LINE_ITEM, + }); + + expect(lineItemLogCount).toBe(lineItems.length); + expect(shippingLogCount).toBe(1); + }); + + it("should not create another payout when there is no line-items fulfilled left", async () => { + await CustomerPayoutServiceCreate({ customerId }); + await expect(CustomerPayoutServiceCreate({ customerId })).rejects.toThrow(); + }); +}); diff --git a/src/functions/customer/services/payout/create.ts b/src/functions/customer/services/payout/create.ts new file mode 100644 index 00000000..8912599c --- /dev/null +++ b/src/functions/customer/services/payout/create.ts @@ -0,0 +1,109 @@ +import { OrderModel } from "~/functions/order/order.models"; +import { OrderLineItem } from "~/functions/order/order.types"; +import { PayoutModel, PayoutStatus } from "~/functions/payout"; +import { PayoutLogModel, PayoutLogReferenceType } from "~/functions/payout-log"; +import { Shipping } from "~/functions/shipping/shipping.types"; +import { NotFoundError } from "~/library/handler"; +import { CustomerPayoutAccountServiceGet } from "../payout-account/get"; +import { lineItemAggregation, shippingAggregation } from "./aggregation"; + +export type CustomerPayoutServiceCreateProps = { + customerId: number; +}; + +export const CustomerPayoutServiceCreate = async ({ + customerId, +}: CustomerPayoutServiceCreateProps) => { + const lineItems = await CustomerPayoutServiceGetLineItemsFulfilled({ + customerId, + }); + + if (lineItems.length === 0) { + throw new NotFoundError([ + { + code: "custom", + message: "LINE_ITEMS_EMPTY", + path: ["customerId"], + }, + ]); + } + + const account = await CustomerPayoutAccountServiceGet({ customerId }); + if (!account) { + throw new NotFoundError([ + { + code: "custom", + message: "PAYOUT_ACCOUNT_NOT_CREATED", + path: ["customerId"], + }, + ]); + } + + const totalLineItems = lineItems.reduce( + (accumulator, { line_items }) => accumulator + parseFloat(line_items.price), + 0 + ); + + const shippings = lineItems + .filter((lineItem) => lineItem.shipping) + .map(({ shipping }) => shipping); + + let uniqueShippings = Array.from( + new Map( + shippings.map((shipping) => [shipping._id.toString(), shipping]) + ).values() + ); + + const totalShippingAmount = uniqueShippings.reduce( + (accumulator, { cost }) => accumulator + cost.value, + 0 + ); + + const payout = new PayoutModel({ + customerId, + date: new Date(), + amount: totalLineItems + totalShippingAmount, + currencyCode: "DKK", + status: PayoutStatus.PENDING, + payoutType: account.payoutType, + payoutDetails: account.payoutDetails, + }); + + PayoutLogModel.insertMany( + uniqueShippings.map((shipping) => ({ + customerId, + referenceId: shipping._id, + referenceType: PayoutLogReferenceType.SHIPPING, + payout: payout._id, + })) + ).catch((error) => console.error("Error inserting shipping logs:", error)); //<< needs to send to application inisight + + PayoutLogModel.insertMany( + lineItems.map((lineItem) => ({ + customerId, + referenceId: lineItem.line_items.id, + referenceType: PayoutLogReferenceType.LINE_ITEM, + payout: payout._id, + })) + ).catch((error) => console.error("Error inserting line item logs:", error)); //<< needs to send to application inisight + + return payout.save(); +}; + +export const CustomerPayoutServiceGetLineItemsFulfilled = async ({ + customerId, +}: CustomerPayoutServiceCreateProps) => { + return OrderModel.aggregate<{ + line_items: OrderLineItem; + shipping: Pick; + }>([ + ...lineItemAggregation({ customerId }), + ...shippingAggregation, + { + $project: { + line_items: "$line_items", + shipping: "$shipping", + }, + }, + ]); +}; diff --git a/src/functions/customer/services/payout/fixtures/dummydata.balance.ts b/src/functions/customer/services/payout/fixtures/dummydata.balance.ts index 6d6dc2ed..f6e8c3be 100644 --- a/src/functions/customer/services/payout/fixtures/dummydata.balance.ts +++ b/src/functions/customer/services/payout/fixtures/dummydata.balance.ts @@ -209,25 +209,6 @@ export const dummyDataBalance = { currency: "DKK", tax_exemptions: [], admin_graphql_api_id: "gid://shopify/Customer/115310627314723954", - default_address: { - id: 715243470612851200, - customer_id: 115310627314723950, - first_name: null, - last_name: null, - company: null, - address1: "123 Elm St.", - address2: null, - city: "Ottawa", - province: "Ontario", - country: "Canada", - zip: "K2H7A8", - phone: "123-123-1234", - name: "", - province_code: "ON", - country_code: "CA", - country_name: "Canada", - default: true, - }, }, discount_applications: [], fulfillments: [], diff --git a/src/functions/customer/services/payout/get.spec.ts b/src/functions/customer/services/payout/get.spec.ts new file mode 100644 index 00000000..ca89ece5 --- /dev/null +++ b/src/functions/customer/services/payout/get.spec.ts @@ -0,0 +1,55 @@ +import { OrderModel } from "~/functions/order/order.models"; +import { Order } from "~/functions/order/order.types"; + +import { faker } from "@faker-js/faker"; +import { createPayoutAccount } from "~/library/jest/helpers/payout-account"; +import { createShipping } from "~/library/jest/helpers/shipping"; +import { CustomerPayoutServiceCreate } from "./create"; +import { dummyDataBalance } from "./fixtures/dummydata.balance"; +import { CustomerPayoutServiceGet } from "./get"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerPayoutServiceGet", () => { + const customerId = 7106990342471; + + beforeEach(async () => { + const shipping = await createShipping({ origin: { customerId } }); + dummyDataBalance.id = faker.number.int({ min: 1000000, max: 100000000 }); + dummyDataBalance.line_items = dummyDataBalance.line_items.map( + (lineItem) => { + lineItem.current_quantity = 1; + lineItem.fulfillable_quantity = 0; + lineItem.fulfillment_status = "fulfilled"; + const price = faker.number.float({ + min: 100, + max: 500, + }); + lineItem.price = price.toFixed(2); + lineItem.properties = [ + { name: "_from", value: "2023-12-14T08:12:00.000Z" }, + { name: "_to", value: "2023-12-14T09:27:00.000Z" }, + { name: "_customerId", value: customerId }, + { name: "_locationId", value: "64a6c1bde5df64bb85935732" }, + { name: "_shippingId", value: shipping._id.toString() }, + ] as any; + lineItem.id = faker.number.int({ min: 1000000, max: 100000000 }); + return lineItem; + } + ); + + await createPayoutAccount({ customerId }); + await OrderModel.create(Order.parse(dummyDataBalance)); + }); + + it("should get payout with line-items", async () => { + const payout = await CustomerPayoutServiceCreate({ customerId }); + + const response = await CustomerPayoutServiceGet({ + customerId, + payoutId: payout._id, + }); + + expect(response).toBeDefined(); + }); +}); diff --git a/src/functions/customer/services/payout/get.ts b/src/functions/customer/services/payout/get.ts new file mode 100644 index 00000000..717ccba9 --- /dev/null +++ b/src/functions/customer/services/payout/get.ts @@ -0,0 +1,26 @@ +import { PayoutModel } from "~/functions/payout"; +import { NotFoundError } from "~/library/handler"; +import { StringOrObjectId } from "~/library/zod"; + +export type CustomerPayoutServiceGetProps = { + customerId: number; + payoutId: StringOrObjectId; +}; + +export const CustomerPayoutServiceGet = async ({ + customerId, + payoutId, +}: CustomerPayoutServiceGetProps) => { + return PayoutModel.findOne({ + _id: payoutId, + customerId, + }).orFail( + new NotFoundError([ + { + path: ["customerId", "payoutId"], + message: "PAYOUT_NOT_FOUND", + code: "custom", + }, + ]) + ); +}; diff --git a/src/functions/customer/services/payout/paginate.spec.ts b/src/functions/customer/services/payout/paginate.spec.ts new file mode 100644 index 00000000..2c30224c --- /dev/null +++ b/src/functions/customer/services/payout/paginate.spec.ts @@ -0,0 +1,83 @@ +import { OrderModel } from "~/functions/order/order.models"; +import { Order } from "~/functions/order/order.types"; + +import { faker } from "@faker-js/faker"; +import { createPayout } from "~/library/jest/helpers/payout"; +import { createPayoutAccount } from "~/library/jest/helpers/payout-account"; +import { createShipping } from "~/library/jest/helpers/shipping"; +import { dummyDataBalance } from "./fixtures/dummydata.balance"; +import { CustomerPayoutServicePaginate } from "./paginate"; + +require("~/library/jest/mongoose/mongodb.jest"); + +describe("CustomerPayoutServiceList", () => { + const customerId = 7106990342471; + + beforeEach(async () => { + const shipping = await createShipping({ origin: { customerId } }); + dummyDataBalance.id = faker.number.int({ min: 1000000, max: 100000000 }); + dummyDataBalance.line_items = dummyDataBalance.line_items.map( + (lineItem) => { + lineItem.current_quantity = 1; + lineItem.fulfillable_quantity = 0; + lineItem.fulfillment_status = "fulfilled"; + const price = faker.number.float({ + min: 100, + max: 500, + }); + lineItem.price = price.toFixed(2); + lineItem.properties = [ + { name: "_from", value: "2023-12-14T08:12:00.000Z" }, + { name: "_to", value: "2023-12-14T09:27:00.000Z" }, + { name: "_customerId", value: customerId }, + { name: "_locationId", value: "64a6c1bde5df64bb85935732" }, + { name: "_shippingId", value: shipping._id.toString() }, + ] as any; + lineItem.id = faker.number.int({ min: 1000000, max: 100000000 }); + return lineItem; + } + ); + + await OrderModel.create(Order.parse(dummyDataBalance)); + }); + + it("create payouts to test pagination", async () => { + const account = await createPayoutAccount({ customerId }); + + await createPayout({ + customerId, + payoutDetails: account.payoutDetails, + payoutType: account.payoutType, + }); + await createPayout({ + customerId, + payoutDetails: account.payoutDetails, + payoutType: account.payoutType, + }); + await createPayout({ + customerId, + payoutDetails: account.payoutDetails, + payoutType: account.payoutType, + }); + await createPayout({ + customerId, + payoutDetails: account.payoutDetails, + payoutType: account.payoutType, + }); + + let result = await CustomerPayoutServicePaginate({ + limit: 2, + customerId, + }); + expect(result.totalPages).toBe(2); + expect(result.hasNextPage).toBeTruthy(); + + result = await CustomerPayoutServicePaginate({ + limit: 2, + page: 2, + customerId, + }); + expect(result.currentPage).toBe(2); + expect(result.hasNextPage).toBeFalsy(); + }); +}); diff --git a/src/functions/customer/services/payout/paginate.ts b/src/functions/customer/services/payout/paginate.ts new file mode 100644 index 00000000..91b6daa4 --- /dev/null +++ b/src/functions/customer/services/payout/paginate.ts @@ -0,0 +1,38 @@ +import { FilterQuery } from "mongoose"; +import { IPayoutDocument, PayoutModel } from "~/functions/payout"; + +export type CustomerPayoutServicePaginateProps = { + page?: number; // Use page number instead of nextCursor + limit?: number; + sortOrder?: "asc" | "desc"; + customerId: number; +}; + +export const CustomerPayoutServicePaginate = async ({ + page = 1, + limit = 10, + sortOrder = "desc", + customerId, +}: CustomerPayoutServicePaginateProps) => { + let query: FilterQuery = { customerId }; + + const sortParam = sortOrder === "asc" ? 1 : -1; + const skip = (page - 1) * limit; + + const totalCount = await PayoutModel.countDocuments(query); + const payouts = await PayoutModel.find(query) + .sort({ createdAt: sortParam }) + .skip(skip) + .limit(limit); + + const totalPages = Math.ceil(totalCount / limit); + const hasNextPage = page < totalPages; + + return { + results: payouts, + currentPage: page, + totalPages, + hasNextPage, + totalCount, + }; +}; diff --git a/src/functions/order/order.schema.ts b/src/functions/order/order.schema.ts index 61f83cf7..ab7fcd22 100644 --- a/src/functions/order/order.schema.ts +++ b/src/functions/order/order.schema.ts @@ -109,10 +109,10 @@ const LineItemSchema = new Schema( index: true, }, admin_graphql_api_id: String, - current_quantity: Number, - fulfillable_quantity: Number, + current_quantity: { type: Number, index: true }, + fulfillable_quantity: { type: Number, index: true }, fulfillment_service: String, - fulfillment_status: String, + fulfillment_status: { type: String, index: true }, gift_card: Boolean, grams: Number, name: String, diff --git a/src/functions/payout-log/payout-log.schema.ts b/src/functions/payout-log/payout-log.schema.ts index 97256462..dfa1a77f 100644 --- a/src/functions/payout-log/payout-log.schema.ts +++ b/src/functions/payout-log/payout-log.schema.ts @@ -1,8 +1,8 @@ import mongoose, { Document, Model } from "mongoose"; -import { PayoutLog } from "./payout-log.types"; +import { PayoutLog, PayoutLogReferenceType } from "./payout-log.types"; export interface IPayoutLog extends Omit { - updatedAt: Date; + createdAt: Date; } export interface IPayoutLogDocument extends IPayoutLog, Document {} @@ -16,11 +16,15 @@ export const PayoutLogMongooseSchema = new mongoose.Schema< type: Number, required: true, }, - lineItemId: { - type: Number, + referenceType: { + type: String, + required: true, + enum: [PayoutLogReferenceType.LINE_ITEM, PayoutLogReferenceType.SHIPPING], + }, + referenceId: { + type: mongoose.Schema.Types.Mixed, required: true, unique: true, - index: true, }, payout: { type: mongoose.Schema.Types.ObjectId, @@ -32,6 +36,8 @@ export const PayoutLogMongooseSchema = new mongoose.Schema< ); PayoutLogMongooseSchema.index( - { customerId: 1, lineItemId: 1 }, + { customerId: 1, referenceType: 1, referenceId: 1 }, { unique: true } ); + +PayoutLogMongooseSchema.index({ customerId: 1, payout: 1 }, { unique: false }); diff --git a/src/functions/payout-log/payout-log.types.ts b/src/functions/payout-log/payout-log.types.ts index 2e7a04ac..606f9123 100644 --- a/src/functions/payout-log/payout-log.types.ts +++ b/src/functions/payout-log/payout-log.types.ts @@ -1,11 +1,16 @@ import { z } from "zod"; import { NumberOrStringType, ObjectIdType } from "~/library/zod"; +export enum PayoutLogReferenceType { + SHIPPING = "Shipping", + LINE_ITEM = "LineItem", +} + export const PayoutLogZodSchema = z.object({ customerId: NumberOrStringType, - lineItemId: NumberOrStringType, + referenceType: z.nativeEnum(PayoutLogReferenceType), + referenceId: z.union([NumberOrStringType, ObjectIdType]), payout: ObjectIdType, - createdAt: z.date(), }); export type PayoutLog = z.infer; diff --git a/src/functions/user/services/availability/generate.spec.ts b/src/functions/user/services/availability/generate.spec.ts index b10efa18..ebcbb684 100644 --- a/src/functions/user/services/availability/generate.spec.ts +++ b/src/functions/user/services/availability/generate.spec.ts @@ -30,22 +30,43 @@ describe("UserAvailabilityServiceGenerate", () => { let schedule: Awaited>; let locationOrigin: Awaited>; let locationDestination: Awaited>; + let days: WeekDays[] = []; + + beforeAll(() => { + jest + .useFakeTimers({ + doNotFake: [ + "nextTick", + "setImmediate", + "clearImmediate", + "setInterval", + "clearInterval", + "setTimeout", + "clearTimeout", + ], + }) + .setSystemTime(new Date("2022-02-15")); + }); + + afterAll(() => { + jest.useRealTimers(); // Go back to real timers + }); - const todayInUTC = utcToZonedTime(new Date(), "Etc/UTC"); + beforeEach(async () => { + const todayInUTC = new Date(); - const nextDayInUTC = format( - addDays(todayInUTC, 1), - "iiii" - ).toLowerCase() as WeekDays; + const nextDayInUTC = format( + addDays(todayInUTC, 1), + "iiii" + ).toLowerCase() as WeekDays; - const dayAfterNextInUTC = format( - addDays(todayInUTC, 2), - "iiii" - ).toLowerCase() as WeekDays; + const dayAfterNextInUTC = format( + addDays(todayInUTC, 2), + "iiii" + ).toLowerCase() as WeekDays; - const days = [nextDayInUTC]; + days = [nextDayInUTC]; - beforeEach(async () => { user = await createUser({ customerId }); locationOrigin = await createLocation({ diff --git a/src/functions/user/services/availability/generate.ts b/src/functions/user/services/availability/generate.ts index 19b158e2..044bc58e 100644 --- a/src/functions/user/services/availability/generate.ts +++ b/src/functions/user/services/availability/generate.ts @@ -20,7 +20,6 @@ export type UserAvailabilityServiceGenerateProps = { export type UserAvailabilityServiceGenerateBody = { productIds: Array; fromDate: string; - toDate?: string; shippingId?: string | Types.ObjectId; }; @@ -53,7 +52,6 @@ export const UserAvailabilityServiceGenerate = async ( schedule, shipping, fromDate: body.fromDate, - toDate: body.toDate, }); if (availability.length === 0) { diff --git a/src/functions/user/services/availability/get.spec.ts b/src/functions/user/services/availability/get.spec.ts index 92c04fc6..0c219dbf 100644 --- a/src/functions/user/services/availability/get.spec.ts +++ b/src/functions/user/services/availability/get.spec.ts @@ -18,16 +18,36 @@ describe("UserAvailabilityServiceGet", () => { let locationOrigin: Awaited>; let locationDestination: Awaited>; - const todayInUTC = utcToZonedTime(new Date(), "Etc/UTC"); - - const nextDayInUTC = format( - addDays(todayInUTC, 1), - "iiii" - ).toLowerCase() as WeekDays; + beforeAll(() => { + jest + .useFakeTimers({ + doNotFake: [ + "nextTick", + "setImmediate", + "clearImmediate", + "setInterval", + "clearInterval", + "setTimeout", + "clearTimeout", + ], + }) + .setSystemTime(new Date("2022-02-15")); + }); - const days = [nextDayInUTC]; + afterAll(() => { + jest.useRealTimers(); // Go back to real timers + }); beforeEach(async () => { + const todayInUTC = utcToZonedTime(new Date(), "Etc/UTC"); + + const nextDayInUTC = format( + addDays(todayInUTC, 1), + "iiii" + ).toLowerCase() as WeekDays; + + const days = [nextDayInUTC]; + user = await createUser({ customerId }); locationOrigin = await createLocation({ diff --git a/src/functions/user/services/user/list.spec.ts b/src/functions/user/services/user/list.spec.ts index f62c1f11..a317eaff 100644 --- a/src/functions/user/services/user/list.spec.ts +++ b/src/functions/user/services/user/list.spec.ts @@ -33,12 +33,12 @@ describe("UserServiceList", () => { const firstPage = await UserServiceList({ limit: 10 }); expect(firstPage.results.length).toBe(10); - expect(firstPage.total).toBe(25); + expect(firstPage.totalCount).toBe(25); const professions = await UserServiceProfessions(); for (const profession in professions) { const result = await UserServiceList({ limit: 10, profession }); - expect(result.total).toBe(professions[profession]); + expect(result.totalCount).toBe(professions[profession]); const specialties = await UserServiceSpecialties({ profession, @@ -49,7 +49,7 @@ describe("UserServiceList", () => { profession, specialties: [specialty], }); - expect(result.total).toBe(specialties[specialty]); + expect(result.totalCount).toBe(specialties[specialty]); } } }); diff --git a/src/functions/user/services/user/list.ts b/src/functions/user/services/user/list.ts index 02edab7c..3264472e 100644 --- a/src/functions/user/services/user/list.ts +++ b/src/functions/user/services/user/list.ts @@ -40,16 +40,18 @@ export const UserServiceList = async ({ }; } - const totalCount = await UserModel.countDocuments(query); const l = limit || 10; const users = await UserModel.find(query) .sort({ createdAt: sortParam }) - .limit(l); + .limit(l + 1); + + const totalCount = await UserModel.countDocuments(query); + const hasNextPage = users.length > l; + const results = hasNextPage ? users.slice(0, -1) : users; return { - results: users, - nextCursor: - users.length >= l ? users[users.length - 1].createdAt : undefined, - total: totalCount, + results, + nextCursor: hasNextPage ? results[results.length - 1].createdAt : undefined, + totalCount, }; }; diff --git a/src/library/availability/generate-availability.spec.ts b/src/library/availability/generate-availability.spec.ts index b9379d7e..fd11b467 100644 --- a/src/library/availability/generate-availability.spec.ts +++ b/src/library/availability/generate-availability.spec.ts @@ -59,6 +59,26 @@ describe("generateAvailability", () => { }, }; + beforeAll(() => { + jest + .useFakeTimers({ + doNotFake: [ + "nextTick", + "setImmediate", + "clearImmediate", + "setInterval", + "clearInterval", + "setTimeout", + "clearTimeout", + ], + }) + .setSystemTime(new Date("2022-02-15")); + }); + + afterAll(() => { + jest.useRealTimers(); // Go back to real timers + }); + beforeEach(() => UserModel.create({ customerId, diff --git a/src/library/jest/helpers/payout.ts b/src/library/jest/helpers/payout.ts new file mode 100644 index 00000000..743b6062 --- /dev/null +++ b/src/library/jest/helpers/payout.ts @@ -0,0 +1,22 @@ +import { faker } from "@faker-js/faker"; +import { Payout, PayoutModel, PayoutStatus } from "~/functions/payout"; + +export const getPayoutObject = ( + props: Partial = {} +): Omit => ({ + amount: faker.number.int({ min: 1, max: 10000000 }), + currencyCode: "DKK", + date: faker.date.between({ from: "2020-01-01", to: "2023-12-31" }), + status: faker.helpers.arrayElement(Object.values(PayoutStatus)), + customerId: faker.number.int({ min: 1, max: 10000000 }), + payoutDetails: props.payoutDetails, + payoutType: props.payoutType, + ...props, +}); + +export const createPayout = ( + props: Partial & Pick = {} +) => { + const payoutAccount = new PayoutModel(getPayoutObject({ ...props })); + return payoutAccount.save(); +}; diff --git a/src/library/jest/helpers/shipping.ts b/src/library/jest/helpers/shipping.ts index cc1d14d1..de559c1c 100644 --- a/src/library/jest/helpers/shipping.ts +++ b/src/library/jest/helpers/shipping.ts @@ -12,7 +12,7 @@ export const DEFAULT_GROUP = "all"; const getOriginObject = (props: Partial = {}): Location => ({ name: faker.person.firstName(), - customerId: faker.number.int({ min: 1, max: 100000 }), + customerId: props.customerId || faker.number.int({ min: 1, max: 100000 }), locationType: LocationTypes.ORIGIN, originType: LocationOriginTypes.COMMERCIAL, fullAddress: faker.location.streetAddress(), @@ -25,11 +25,13 @@ const getOriginObject = (props: Partial = {}): Location => ({ ...props, }); -export const createShipping = (filter: Partial) => { +export const createShipping = ( + filter: Partial> & { origin?: Partial } +) => { const shipping = new ShippingModel(); shipping.location = filter.location?.toString() || new mongoose.Types.ObjectId(); - shipping.origin = getOriginObject({}); + shipping.origin = getOriginObject(filter.origin || {}); shipping.destination = { name: faker.company.buzzPhrase(), fullAddress: faker.location.streetAddress(), diff --git a/src/library/zod/index.ts b/src/library/zod/index.ts index 123e8788..1bad5b22 100644 --- a/src/library/zod/index.ts +++ b/src/library/zod/index.ts @@ -63,6 +63,8 @@ export const NumberOrStringType = z typeof value === "string" ? parseInt(value, 10) : value ); +export type NumberOrString = z.infer; + export const isValidObjectId = (value: any): value is string => mongoose.Types.ObjectId.isValid(value);