diff --git a/integration-tests/modules/__tests__/cart/store/carts.spec.ts b/integration-tests/modules/__tests__/cart/store/carts.spec.ts index cb68389932..ab1aed0098 100644 --- a/integration-tests/modules/__tests__/cart/store/carts.spec.ts +++ b/integration-tests/modules/__tests__/cart/store/carts.spec.ts @@ -1972,6 +1972,10 @@ medusaIntegrationTestRunner({ ) }) + afterEach(async () => { + jest.clearAllMocks() + }) + it("should create an order and create item reservations", async () => { const cart = ( await api.post(`/store/carts`, { @@ -2136,6 +2140,62 @@ medusaIntegrationTestRunner({ }) }) + it("should fail to update cart when it is completed", async () => { + const cart = ( + await api.post(`/store/carts`, { + currency_code: "usd", + email: "tony@stark-industries.com", + shipping_address: { + address_1: "test address 1", + address_2: "test address 2", + city: "ny", + country_code: "us", + province: "ny", + postal_code: "94016", + }, + sales_channel_id: salesChannel.id, + items: [{ quantity: 1, variant_id: product.variants[0].id }], + }) + ).data.cart + + const paymentCollection = ( + await api.post(`/store/payment-collections`, { + cart_id: cart.id, + }) + ).data.payment_collection + + await api.post( + `/store/payment-collections/${paymentCollection.id}/payment-sessions`, + { provider_id: "pp_system_default" } + ) + + await api.post(`/store/carts/${cart.id}/complete`, {}) + + const cartRefetch = (await api.get(`/store/carts/${cart.id}`)).data + .cart + + expect(cartRefetch.completed_at).toBeTruthy() + + await expect( + api.post(`/store/carts/${cart.id}/shipping-methods`, { + option_id: shippingOption.id, + }) + ).rejects.toThrow() + + const error = await api + .post(`/store/carts/${cart.id}/line-items`, { + variant_id: product.variants[0].id, + quantity: 1, + }) + .catch((e) => e) + + expect(error.response.status).toEqual(400) + expect(error.response.data).toEqual({ + type: "invalid_data", + message: `Cart ${cart.id} is already completed.`, + }) + }) + it("should return cart when payment authorization fails", async () => { const authorizePaymentSessionSpy = jest.spyOn( PaymentModuleService.prototype, diff --git a/packages/core/core-flows/src/cart/steps/validate-cart.ts b/packages/core/core-flows/src/cart/steps/validate-cart.ts new file mode 100644 index 0000000000..b4115f3901 --- /dev/null +++ b/packages/core/core-flows/src/cart/steps/validate-cart.ts @@ -0,0 +1,32 @@ +import { CartDTO, CartWorkflowDTO } from "@medusajs/types" +import { MedusaError, isPresent } from "@medusajs/utils" +import { createStep } from "@medusajs/workflows-sdk" + +export interface ValidateCartStepInput { + cart: CartWorkflowDTO | CartDTO +} + +export const validateCartStepId = "validate-cart" +/** + * This step validates a cart's before editing it. + */ +export const validateCartStep = createStep( + validateCartStepId, + async (data: ValidateCartStepInput) => { + const { cart } = data + + if (!isPresent(cart)) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cart does not exist` + ) + } + + if (cart.completed_at) { + throw new MedusaError( + MedusaError.Types.INVALID_DATA, + `Cart ${cart.id} is already completed.` + ) + } + } +) diff --git a/packages/core/core-flows/src/cart/utils/fields.ts b/packages/core/core-flows/src/cart/utils/fields.ts index 44c41bc19c..bb804db7f7 100644 --- a/packages/core/core-flows/src/cart/utils/fields.ts +++ b/packages/core/core-flows/src/cart/utils/fields.ts @@ -1,9 +1,11 @@ export const cartFieldsForRefreshSteps = [ + "id", "subtotal", "item_subtotal", "shipping_subtotal", "region_id", "currency_code", + "completed_at", "region.*", "items.*", "items.product.id", @@ -26,6 +28,7 @@ export const completeCartFields = [ "email", "created_at", "updated_at", + "completed_at", "total", "subtotal", "tax_total", diff --git a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts index 66b20bda4e..4afa9f3669 100644 --- a/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-shipping-method-to-cart.ts @@ -9,6 +9,7 @@ import { removeShippingMethodFromCartStep, validateCartShippingOptionsStep, } from "../steps" +import { validateCartStep } from "../steps/validate-cart" import { cartFieldsForRefreshSteps } from "../utils/fields" import { updateCartPromotionsWorkflow } from "./update-cart-promotions" import { updateTaxLinesWorkflow } from "./update-tax-lines" @@ -37,6 +38,8 @@ export const addShippingMethodToWorkflow = createWorkflow( list: false, }) + validateCartStep({ cart }) + const optionIds = transform({ input }, (data) => { return (data.input.options ?? []).map((i) => i.id) }) diff --git a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts index f3b4caf68f..1e7a816999 100644 --- a/packages/core/core-flows/src/cart/workflows/add-to-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/add-to-cart.ts @@ -16,6 +16,7 @@ import { refreshCartShippingMethodsStep, updateLineItemsStep, } from "../steps" +import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { cartFieldsForRefreshSteps, @@ -34,6 +35,8 @@ export const addToCartWorkflowId = "add-to-cart" export const addToCartWorkflow = createWorkflow( addToCartWorkflowId, (input: WorkflowData) => { + validateCartStep(input) + const variantIds = transform({ input }, (data) => { return (data.input.items ?? []).map((i) => i.variant_id) }) @@ -67,9 +70,10 @@ export const addToCartWorkflow = createWorkflow( return prepareLineItemData({ variant: variant, - unitPrice: item.unit_price || - variant.calculated_price.calculated_amount, - isTaxInclusive: item.is_tax_inclusive || + unitPrice: + item.unit_price || variant.calculated_price.calculated_amount, + isTaxInclusive: + item.is_tax_inclusive || variant.calculated_price.is_calculated_price_tax_inclusive, quantity: item.quantity, metadata: item?.metadata ?? {}, diff --git a/packages/core/core-flows/src/cart/workflows/complete-cart.ts b/packages/core/core-flows/src/cart/workflows/complete-cart.ts index 17d9550068..fd1032c4e4 100644 --- a/packages/core/core-flows/src/cart/workflows/complete-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/complete-cart.ts @@ -14,8 +14,9 @@ import { } from "../../common" import { createOrdersStep } from "../../order/steps/create-orders" import { authorizePaymentSessionStep } from "../../payment/steps/authorize-payment-session" -import { validateCartPaymentsStep } from "../steps" +import { updateCartsStep, validateCartPaymentsStep } from "../steps" import { reserveInventoryStep } from "../steps/reserve-inventory" +import { validateCartStep } from "../steps/validate-cart" import { completeCartFields } from "../utils/fields" import { prepareConfirmInventoryInput } from "../utils/prepare-confirm-inventory-input" import { @@ -44,6 +45,8 @@ export const completeCartWorkflow = createWorkflow( list: false, }) + validateCartStep({ cart }) + const paymentSessions = validateCartPaymentsStep({ cart }) authorizePaymentSessionStep({ @@ -158,6 +161,13 @@ export const completeCartWorkflow = createWorkflow( ({ createdOrders }) => createdOrders[0] ) + const updateCompletedAt = transform({ cart }, ({ cart }) => { + return { + id: cart.id, + completed_at: new Date(), + } + }) + parallelize( createRemoteLinkStep([ { @@ -171,6 +181,7 @@ export const completeCartWorkflow = createWorkflow( }, }, ]), + updateCartsStep([updateCompletedAt]), emitEventStep({ eventName: OrderWorkflowEvents.PLACED, data: { id: order.id }, diff --git a/packages/core/core-flows/src/cart/workflows/create-payment-collection-for-cart.ts b/packages/core/core-flows/src/cart/workflows/create-payment-collection-for-cart.ts index 723f851479..1c7052bfa1 100644 --- a/packages/core/core-flows/src/cart/workflows/create-payment-collection-for-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/create-payment-collection-for-cart.ts @@ -12,6 +12,7 @@ import { import { createRemoteLinkStep } from "../../common/steps/create-remote-links" import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { createPaymentCollectionsStep } from "../steps/create-payment-collection" +import { validateCartStep } from "../steps/validate-cart" /** * This step validates that a cart doesn't have a payment collection. @@ -40,6 +41,7 @@ export const createPaymentCollectionForCartWorkflow = createWorkflow( fields: [ "id", "region_id", + "completed_at", "currency_code", "total", "raw_total", @@ -50,6 +52,8 @@ export const createPaymentCollectionForCartWorkflow = createWorkflow( list: false, }) + validateCartStep({ cart }) + validateExistingPaymentCollectionStep({ cart }) const paymentData = transform({ cart }, ({ cart }) => { diff --git a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts index 51c23396d3..fd578d2908 100644 --- a/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts +++ b/packages/core/core-flows/src/cart/workflows/update-line-item-in-cart.ts @@ -8,6 +8,7 @@ import { import { useRemoteQueryStep } from "../../common/steps/use-remote-query" import { updateLineItemsStepWithSelector } from "../../line-item/steps" import { refreshCartShippingMethodsStep } from "../steps" +import { validateCartStep } from "../steps/validate-cart" import { validateVariantPricesStep } from "../steps/validate-variant-prices" import { cartFieldsForRefreshSteps, @@ -27,6 +28,8 @@ export const updateLineItemInCartWorkflowId = "update-line-item-in-cart" export const updateLineItemInCartWorkflow = createWorkflow( updateLineItemInCartWorkflowId, (input: WorkflowData) => { + validateCartStep(input) + const variantIds = transform({ input }, (data) => { return [data.input.item.variant_id] }) diff --git a/packages/core/types/src/cart/common.ts b/packages/core/types/src/cart/common.ts index d2bfc9f0a9..9bca584517 100644 --- a/packages/core/types/src/cart/common.ts +++ b/packages/core/types/src/cart/common.ts @@ -782,6 +782,11 @@ export interface CartDTO { */ metadata?: Record | null + /** + * When the cart was completed. + */ + completed_at?: string | Date + /** * When the cart was created. */ diff --git a/packages/medusa/src/api/store/carts/query-config.ts b/packages/medusa/src/api/store/carts/query-config.ts index dee9cf5300..658ee7d49b 100644 --- a/packages/medusa/src/api/store/carts/query-config.ts +++ b/packages/medusa/src/api/store/carts/query-config.ts @@ -4,6 +4,7 @@ export const defaultStoreCartFields = [ "email", "created_at", "updated_at", + "completed_at", "total", "subtotal", "tax_total", diff --git a/packages/modules/cart/src/migrations/Migration20240831125857.ts b/packages/modules/cart/src/migrations/Migration20240831125857.ts new file mode 100644 index 0000000000..b3024fb75c --- /dev/null +++ b/packages/modules/cart/src/migrations/Migration20240831125857.ts @@ -0,0 +1,15 @@ +import { Migration } from "@mikro-orm/migrations" + +export class Migration20240831125857 extends Migration { + async up(): Promise { + this.addSql( + 'alter table if exists "cart" add column if not exists "completed_at" timestamptz null;' + ) + } + + async down(): Promise { + this.addSql( + 'alter table if exists "cart" drop column if exists "completed_at";' + ) + } +} diff --git a/packages/modules/cart/src/models/cart.ts b/packages/modules/cart/src/models/cart.ts index 99a92853f1..41f2236258 100644 --- a/packages/modules/cart/src/models/cart.ts +++ b/packages/modules/cart/src/models/cart.ts @@ -147,6 +147,9 @@ export default class Cart { }) shipping_methods = new Collection>(this) + @Property({ columnType: "timestamptz", nullable: true }) + completed_at: Date | null = null + @Property({ onCreate: () => new Date(), columnType: "timestamptz",