Files
medusa-store/packages/medusa/src/models/cart.ts
T
Sebastian Rindom 39f2c0c15e fix(medusa): calculates correct taxes and totals on order with gift cards (#1807)
**What**
Since the release of the Tax API the line item totals calculations on orders with gift cards have been wrong. To understand the bug consider the below order:

Region:
- tax_rate: 25%
- gift_cards_taxable: true

Order:
- applied gift card: 1000
- items: 
  - A: unit_price: 1000
  - B: unit_price: 500
- Subtotal: 1500

**Previous calculation method**
1. Determine how much of the gift card is used for each item using `item_total / subtotal * gift_card_amount`:
  - Item A: 1000/1500 * 1000 = 666.67
  - Item B: 500/1500 * 1000 = 333.33
2. Calculate line item totals including taxes using `(unit_price - gift_card) * (1 + tax_rate)`
  - Item A: 1000 - 666.67 = 333.33; vat amount -> 83.33
  - Item B: 500 - 333.33 = 166.67; vat amount -> 41.67
3. Add up the line item totals: order subtotal = 500; vat amount = 125; total = 625

This is all correct at the totals level; but at the line item level we should still use the "original prices" i.e. the line item total for item a should be (1000 * 1.25) = 1250 with a tax amount of 250. 

**New calculation method**
1. Use default totals calculations
  - Item A: subtotal: 1000, tax_total: 250, total: 1250
  - Item B: subtotal: 500, tax_total: 125, total: 625
2. Add up the line item totals: subtotal: 1500, tax_total: 375, total: 1875
3. Reduce total with gift card: subtotal: 1500 - 1000 = 500, tax_total: 375 - 250 = 125, total = 625

Totals can now be forwarded correctly to accounting plugins.

Fixes CORE-310.
2022-07-11 12:18:43 +00:00

269 lines
6.1 KiB
TypeScript

/**
* @schema cart
* title: "Cart"
* description: "Represents a user cart"
* x-resourceId: cart
* properties:
* id:
* type: string
* email:
* type: string
* billing_address_id:
* type: string
* billing_address:
* $ref: "#/components/schemas/address"
* shipping_address_id:
* type: string
* shipping_address:
* $ref: "#/components/schemas/address"
* items:
* type: array
* items:
* $ref: "#/components/schemas/line_item"
* region_id:
* type: string
* region:
* $ref: "#/components/schemas/region"
* discounts:
* type: array
* items:
* $ref: "#/components/schemas/region"
* gift_cards:
* type: array
* items:
* $ref: "#/components/schemas/gift_card"
* customer_id:
* type: string
* customer:
* $ref: "#/components/schemas/customer"
* payment_session:
* $ref: "#/components/schemas/payment_session"
* payment_sessions:
* type: array
* items:
* $ref: "#/components/schemas/payment_session"
* payment:
* $ref: "#/components/schemas/payment"
* shipping_methods:
* type: array
* items:
* $ref: "#/components/schemas/shipping_method"
* type:
* type: string
* enum:
* - default
* - swap
* - payment_link
* completed_at:
* type: string
* format: date-time
* created_at:
* type: string
* format: date-time
* updated_at:
* type: string
* format: date-time
* deleted_at:
* type: string
* format: date-time
* metadata:
* type: object
* shipping_total:
* type: integer
* discount_total:
* type: integer
* tax_total:
* type: integer
* subtotal:
* type: integer
* refundable_amount:
* type: integer
* gift_card_total:
* type: integer
*/
import {
AfterLoad,
BeforeInsert,
Column,
Entity,
Index,
JoinColumn,
JoinTable,
ManyToMany,
ManyToOne,
OneToMany,
OneToOne,
} from "typeorm"
import { DbAwareColumn, resolveDbType } from "../utils/db-aware-column"
import { Address } from "./address"
import { Customer } from "./customer"
import { Discount } from "./discount"
import { GiftCard } from "./gift-card"
import { LineItem } from "./line-item"
import { Payment } from "./payment"
import { PaymentSession } from "./payment-session"
import { Region } from "./region"
import { ShippingMethod } from "./shipping-method"
import { SoftDeletableEntity } from "../interfaces/models/soft-deletable-entity"
import { generateEntityId } from "../utils/generate-entity-id"
import {
FeatureFlagColumn,
FeatureFlagDecorators,
} from "../utils/feature-flag-decorators"
import { SalesChannel } from "./sales-channel"
export enum CartType {
DEFAULT = "default",
SWAP = "swap",
DRAFT_ORDER = "draft_order",
PAYMENT_LINK = "payment_link",
CLAIM = "claim",
}
@Entity()
export class Cart extends SoftDeletableEntity {
readonly object = "cart"
@Column({ nullable: true })
email: string
@Index()
@Column({ nullable: true })
billing_address_id: string
@ManyToOne(() => Address, {
cascade: ["insert", "remove", "soft-remove"],
})
@JoinColumn({ name: "billing_address_id" })
billing_address: Address
@Index()
@Column({ nullable: true })
shipping_address_id: string
@ManyToOne(() => Address, {
cascade: ["insert", "remove", "soft-remove"],
})
@JoinColumn({ name: "shipping_address_id" })
shipping_address: Address | null
@OneToMany(() => LineItem, (lineItem) => lineItem.cart, {
cascade: ["insert", "remove"],
})
items: LineItem[]
@Index()
@Column()
region_id: string
@ManyToOne(() => Region)
@JoinColumn({ name: "region_id" })
region: Region
@ManyToMany(() => Discount)
@JoinTable({
name: "cart_discounts",
joinColumn: {
name: "cart_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "discount_id",
referencedColumnName: "id",
},
})
discounts: Discount[]
@ManyToMany(() => GiftCard)
@JoinTable({
name: "cart_gift_cards",
joinColumn: {
name: "cart_id",
referencedColumnName: "id",
},
inverseJoinColumn: {
name: "gift_card_id",
referencedColumnName: "id",
},
})
gift_cards: GiftCard[]
@Index()
@Column({ nullable: true })
customer_id: string
@ManyToOne(() => Customer)
@JoinColumn({ name: "customer_id" })
customer: Customer
payment_session: PaymentSession | null
@OneToMany(() => PaymentSession, (paymentSession) => paymentSession.cart, {
cascade: true,
})
payment_sessions: PaymentSession[]
@Index()
@Column({ nullable: true })
payment_id: string
@OneToOne(() => Payment)
@JoinColumn({ name: "payment_id" })
payment: Payment
@OneToMany(() => ShippingMethod, (method) => method.cart, {
cascade: ["soft-remove", "remove"],
})
shipping_methods: ShippingMethod[]
@DbAwareColumn({ type: "enum", enum: CartType, default: "default" })
type: CartType
@Column({ type: resolveDbType("timestamptz"), nullable: true })
completed_at: Date
@Column({ type: resolveDbType("timestamptz"), nullable: true })
payment_authorized_at: Date
@Column({ nullable: true })
idempotency_key: string
@DbAwareColumn({ type: "jsonb", nullable: true })
context: Record<string, unknown>
@DbAwareColumn({ type: "jsonb", nullable: true })
metadata: Record<string, unknown>
@FeatureFlagColumn("sales_channels", { type: "varchar", nullable: true })
sales_channel_id: string | null
@FeatureFlagDecorators("sales_channels", [
ManyToOne(() => SalesChannel),
JoinColumn({ name: "sales_channel_id" }),
])
sales_channel: SalesChannel
shipping_total?: number
discount_total?: number
tax_total?: number | null
refunded_total?: number
total?: number
subtotal?: number
refundable_amount?: number
gift_card_total?: number
gift_card_tax_total?: number
@AfterLoad()
private afterLoad(): void {
if (this.payment_sessions) {
this.payment_session = this.payment_sessions.find((p) => p.is_selected)!
}
}
@BeforeInsert()
private beforeInsert(): void {
this.id = generateEntityId(this.id, "cart")
}
}