Putiikkipalvelu Docs
SDK

Discount Codes

Apply and manage discount codes using the Storefront SDK

Overview

The discountCode resource provides methods for applying, retrieving, and removing discount codes from the cart. Discount codes provide percentage or fixed amount discounts at checkout.

Key Features:

  • Two discount types: percentage (e.g., 20% off) or fixed amount (e.g., 5€ off)
  • Automatic campaign conflict detection (discount codes cannot be used with BuyXPayY campaigns)
  • Stored in cart (Redis) and automatically applied at checkout
  • Validation for expiry, usage limits, and minimum order requirements

Important: Discount codes and BuyXPayY campaigns are mutually exclusive. When a campaign applies to the cart, discount codes cannot be used. The SDK checks for this conflict before calling the API.


Campaign Conflict Rule

Discount codes cannot be combined with BuyXPayY campaigns. When cart items qualify for a campaign discount:

  1. Applying a code → Returns error CAMPAIGN_ACTIVE
  2. Code already applied + cart changes to qualify for campaign → Code is automatically removed during cart validation

The SDK performs client-side campaign conflict checking for instant feedback, but the backend also validates at checkout.


Methods

discountCode.apply(params)

Apply a discount code to the cart.

try {
  const result = await storefront.discountCode.apply({
    code: "SUMMER20",
    cartId,
    cartItems: cart.items,
    campaigns: storeConfig.campaigns,
  });

  console.log(result.discount);
  // { code: "SUMMER20", discountType: "PERCENTAGE", discountValue: 20, minOrderAmount: 5000 }
} catch (error) {
  if (error instanceof StorefrontError && error.code === "CAMPAIGN_ACTIVE") {
    toast.error("Alennuskoodia ei voi käyttää kun kampanja on voimassa");
  }
}

Parameters:

ParamTypeRequiredDescription
codestringYesDiscount code to apply
cartIdstringNoCart ID for guest users
sessionIdstringNoSession ID for logged-in users
cartItemsCartItem[]NoCart items for campaign conflict check
campaignsCampaign[]NoActive campaigns for conflict check

Returns: Promise<ApplyDiscountResponse>

interface ApplyDiscountResponse {
  success: boolean;
  discount: {
    code: string;
    discountType: "PERCENTAGE" | "FIXED_AMOUNT";
    discountValue: number;  // Percentage (1-100) or cents
    minOrderAmount: number; // Minimum order in cents
  };
}

Throws:

  • StorefrontError with various error codes (see Error Codes)

discountCode.get(params?)

Get the currently applied discount code from the cart.

const { discount } = await storefront.discountCode.get({ cartId });

if (discount) {
  console.log(`Code: ${discount.code}`);
  console.log(`Type: ${discount.discountType}`);
  console.log(`Value: ${discount.discountValue}`);
}

Parameters:

ParamTypeRequiredDescription
cartIdstringNoCart ID for guest users
sessionIdstringNoSession ID for logged-in users

Returns: Promise<GetDiscountResponse>

interface GetDiscountResponse {
  discount: AppliedDiscount | null;
}

interface AppliedDiscount {
  code: string;
  discountType: "PERCENTAGE" | "FIXED_AMOUNT";
  discountValue: number;
}

discountCode.remove(params?)

Remove the currently applied discount code from the cart.

await storefront.discountCode.remove({ cartId });

Parameters:

ParamTypeRequiredDescription
cartIdstringNoCart ID for guest users
sessionIdstringNoSession ID for logged-in users

Returns: Promise<RemoveDiscountResponse>

interface RemoveDiscountResponse {
  success: boolean;
}

Note: Safe to call even if no discount is applied (returns success: true).


Error Codes

When applying a discount code fails, the error includes a code field:

CodeDescription
NOT_FOUNDDiscount code doesn't exist
INACTIVECode is disabled by store owner
NOT_STARTEDCode's valid date range hasn't started
EXPIREDCode has passed its end date
MAX_USES_REACHEDCode's usage limit exceeded
MIN_ORDER_NOT_METCart total below minimum order
CAMPAIGN_ACTIVECannot use with active campaign
CART_MISSINGNo cart ID or session provided

Utility Functions

calculateDiscountAmount(subtotal, discount)

Calculate the discount amount in cents.

import { calculateDiscountAmount } from '@putiikkipalvelu/storefront-sdk';

// Percentage discount: 20% off 100€
const amount1 = calculateDiscountAmount(10000, {
  discountType: "PERCENTAGE",
  discountValue: 20
});
// Returns: 2000 (20€ in cents)

// Fixed amount discount: 5€ off
const amount2 = calculateDiscountAmount(10000, {
  discountType: "FIXED_AMOUNT",
  discountValue: 500
});
// Returns: 500 (5€ in cents)

// Fixed discount capped at subtotal
const amount3 = calculateDiscountAmount(300, {
  discountType: "FIXED_AMOUNT",
  discountValue: 500
});
// Returns: 300 (can't discount more than total)

formatDiscountValue(discount, options?)

Format a discount value for display.

import { formatDiscountValue } from '@putiikkipalvelu/storefront-sdk';

// Percentage discount
formatDiscountValue({ discountType: "PERCENTAGE", discountValue: 20 });
// Returns: "-20%"

// Fixed amount discount (Finnish locale)
formatDiscountValue({ discountType: "FIXED_AMOUNT", discountValue: 500 });
// Returns: "-5,00 €"

// Custom currency and position
formatDiscountValue(
  { discountType: "FIXED_AMOUNT", discountValue: 1000 },
  { currencySymbol: "$", currencyPosition: "before" }
);
// Returns: "-$10.00"

Options:

OptionTypeDefaultDescription
currencySymbolstring"€"Currency symbol
currencyPosition"before" | "after""after"Symbol position
decimalsnumber2Decimal places
showMinusbooleantrueShow minus sign prefix

getDiscountApplyErrorMessage(errorCode, locale?)

Get a user-friendly error message for discount apply errors.

import { getDiscountApplyErrorMessage } from '@putiikkipalvelu/storefront-sdk';

getDiscountApplyErrorMessage("EXPIRED", "fi");
// Returns: "Alennuskoodi on vanhentunut"

getDiscountApplyErrorMessage("EXPIRED", "en");
// Returns: "Discount code has expired"

getDiscountRemovalMessage(reason, locale?)

Get a user-friendly message when discount is removed during validation.

import { getDiscountRemovalMessage } from '@putiikkipalvelu/storefront-sdk';

getDiscountRemovalMessage("CAMPAIGN_ACTIVE", "fi");
// Returns: "Alennuskoodi poistettu - kampanja-alennus aktivoitui"

getDiscountRemovalMessage("MIN_ORDER_NOT_MET", "en");
// Returns: "Discount code removed - cart total below minimum order"

Cart Validation Integration

The cart validation endpoint checks discount codes and may remove them. Use the changes object to notify users:

const { hasChanges, changes } = await storefront.cart.validate(
  { cartId },
  cartItems,
  campaigns  // Pass campaigns for conflict detection
);

if (changes.discountCouponRemoved) {
  const message = getDiscountRemovalMessage(
    changes.discountRemovalReason,
    'fi'
  );
  toast.warning(message);
}

Removal Reasons:

ReasonDescription
CAMPAIGN_ACTIVECart now qualifies for a campaign discount
MIN_ORDER_NOT_METCart total dropped below minimum order
CODE_INVALIDCode was deactivated or expired

Examples

Discount Code Input Component

'use client';

import { useState } from 'react';
import { storefront, StorefrontError, getDiscountApplyErrorMessage } from '@/lib/storefront';

interface DiscountCodeInputProps {
  cartId: string;
  cartItems: CartItem[];
  campaigns: Campaign[];
  onApplied: (discount: AppliedDiscount) => void;
}

export function DiscountCodeInput({
  cartId,
  cartItems,
  campaigns,
  onApplied
}: DiscountCodeInputProps) {
  const [code, setCode] = useState('');
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  const handleApply = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!code.trim()) return;

    setIsLoading(true);
    setError(null);

    try {
      const result = await storefront.discountCode.apply({
        code: code.trim(),
        cartId,
        cartItems,
        campaigns,
      });

      onApplied(result.discount);
      setCode('');
    } catch (err) {
      if (err instanceof StorefrontError) {
        setError(getDiscountApplyErrorMessage(err.code, 'fi'));
      } else {
        setError('Virhe alennuskoodin käytössä');
      }
    } finally {
      setIsLoading(false);
    }
  };

  return (
    <form onSubmit={handleApply} className="space-y-2">
      <div className="flex gap-2">
        <input
          type="text"
          value={code}
          onChange={(e) => setCode(e.target.value.toUpperCase())}
          placeholder="Alennuskoodi"
          className="flex-1 px-3 py-2 border rounded"
          disabled={isLoading}
        />
        <button
          type="submit"
          disabled={isLoading || !code.trim()}
          className="px-4 py-2 bg-primary text-white rounded disabled:opacity-50"
        >
          {isLoading ? 'Ladataan...' : 'Käytä'}
        </button>
      </div>
      {error && (
        <p className="text-sm text-red-600">{error}</p>
      )}
    </form>
  );
}

Applied Discount Display

'use client';

import { formatDiscountValue, calculateDiscountAmount } from '@putiikkipalvelu/storefront-sdk';

interface AppliedDiscountDisplayProps {
  discount: AppliedDiscount;
  subtotal: number;  // Cart subtotal in cents
  onRemove: () => void;
}

export function AppliedDiscountDisplay({
  discount,
  subtotal,
  onRemove
}: AppliedDiscountDisplayProps) {
  const discountAmount = calculateDiscountAmount(subtotal, discount);
  const formattedValue = formatDiscountValue(discount);

  return (
    <div className="flex items-center justify-between p-3 bg-green-50 border border-green-200 rounded">
      <div>
        <span className="font-medium text-green-800">{discount.code}</span>
        <span className="ml-2 text-green-600">{formattedValue}</span>
      </div>
      <div className="flex items-center gap-3">
        <span className="text-green-700">
          -{(discountAmount / 100).toFixed(2)} €
        </span>
        <button
          onClick={onRemove}
          className="text-sm text-red-600 hover:underline"
        >
          Poista
        </button>
      </div>
    </div>
  );
}

Complete Cart Summary with Discounts

'use client';

import { useMemo, useEffect, useState } from 'react';
import {
  calculateCartWithCampaigns,
  calculateDiscountAmount,
  formatDiscountValue
} from '@putiikkipalvelu/storefront-sdk';
import { storefront } from '@/lib/storefront';
import { DiscountCodeInput } from './DiscountCodeInput';
import { AppliedDiscountDisplay } from './AppliedDiscountDisplay';

interface CartSummaryProps {
  cartId: string;
  cartItems: CartItem[];
  campaigns: Campaign[];
}

export function CartSummary({ cartId, cartItems, campaigns }: CartSummaryProps) {
  const [discount, setDiscount] = useState<AppliedDiscount | null>(null);

  // Load existing discount on mount
  useEffect(() => {
    async function loadDiscount() {
      const { discount } = await storefront.discountCode.get({ cartId });
      setDiscount(discount);
    }
    loadDiscount();
  }, [cartId]);

  // Calculate cart totals with campaigns
  const calculation = useMemo(() => {
    return calculateCartWithCampaigns(cartItems, campaigns);
  }, [cartItems, campaigns]);

  // Calculate discount amount (only if no campaign applies)
  const discountAmount = useMemo(() => {
    if (!discount || calculation.totalSavings > 0) return 0;
    return calculateDiscountAmount(calculation.cartTotal, discount);
  }, [discount, calculation]);

  const finalTotal = calculation.cartTotal - discountAmount;

  const handleRemoveDiscount = async () => {
    await storefront.discountCode.remove({ cartId });
    setDiscount(null);
  };

  // Check if campaign is active (discount codes not allowed)
  const campaignActive = calculation.totalSavings > 0;

  return (
    <div className="space-y-4 p-4 bg-gray-50 rounded">
      {/* Subtotal */}
      <div className="flex justify-between">
        <span>Välisumma</span>
        <span>{(calculation.originalTotal / 100).toFixed(2)} €</span>
      </div>

      {/* Campaign savings (if any) */}
      {calculation.totalSavings > 0 && (
        <div className="flex justify-between text-green-600">
          <span>Kampanja-alennus</span>
          <span>-{(calculation.totalSavings / 100).toFixed(2)} €</span>
        </div>
      )}

      {/* Discount code section */}
      {discount && !campaignActive ? (
        <AppliedDiscountDisplay
          discount={discount}
          subtotal={calculation.cartTotal}
          onRemove={handleRemoveDiscount}
        />
      ) : !campaignActive ? (
        <DiscountCodeInput
          cartId={cartId}
          cartItems={cartItems}
          campaigns={campaigns}
          onApplied={setDiscount}
        />
      ) : (
        <p className="text-sm text-gray-500">
          Alennuskoodia ei voi käyttää kun kampanja on voimassa
        </p>
      )}

      {/* Discount code savings */}
      {discountAmount > 0 && (
        <div className="flex justify-between text-green-600">
          <span>Alennuskoodi ({discount?.code})</span>
          <span>-{(discountAmount / 100).toFixed(2)} €</span>
        </div>
      )}

      <hr />

      {/* Final total */}
      <div className="flex justify-between text-xl font-bold">
        <span>Yhteensä</span>
        <span>{(finalTotal / 100).toFixed(2)} €</span>
      </div>
    </div>
  );
}

Order Confirmation

After checkout, the order includes discount information:

interface Order {
  // ... other fields
  discountCodeValue: string | null;  // "SUMMER20"
  discountAmount: number | null;     // Amount in cents
  discountVatRate: number | null;    // VAT rate used for calculation
}

Display on confirmation page:

{order.discountCodeValue && (
  <div className="flex justify-between text-green-600">
    <span>Alennuskoodi ({order.discountCodeValue})</span>
    <span>-{(order.discountAmount! / 100).toFixed(2)} €</span>
  </div>
)}

Error Handling

import {
  StorefrontError,
  getDiscountApplyErrorMessage
} from '@putiikkipalvelu/storefront-sdk';

try {
  await storefront.discountCode.apply({
    code: "INVALIDCODE",
    cartId,
    cartItems,
    campaigns
  });
} catch (error) {
  if (error instanceof StorefrontError) {
    // Get localized error message
    const message = getDiscountApplyErrorMessage(error.code, 'fi');
    toast.error(message);

    // Handle specific error codes
    switch (error.code) {
      case 'NOT_FOUND':
        // Code doesn't exist
        break;
      case 'EXPIRED':
        // Code has expired
        break;
      case 'MIN_ORDER_NOT_MET':
        // Cart total too low
        break;
      case 'CAMPAIGN_ACTIVE':
        // Campaign discount takes priority
        break;
    }
  }
}

On this page