Repository: be-BOP-io-SA/be-BOP
Pull request #1814: 🦋 Improve and add new mode in Discount interface #1741
Author: login: ithiame
Created: 2025-04-16T16:30:53Z | Merged: 2025-05-10T13:58:01Z



Diff:
diff --git a/src/lib/components/OrderSummary.svelte b/src/lib/components/OrderSummary.svelte
index bb4aa67eb..da6f17d58 100644
--- a/src/lib/components/OrderSummary.svelte
+++ b/src/lib/components/OrderSummary.svelte
@@ -23,7 +23,11 @@
 		items: Array<
 			Pick<
 				Order['items'][0],
-				'currencySnapshot' | 'quantity' | 'depositPercentage' | 'discountPercentage'
+				| 'currencySnapshot'
+				| 'quantity'
+				| 'depositPercentage'
+				| 'discountPercentage'
+				| 'freeQuantity'
 			> & {
 				digitalFiles: Array<{ _id: string }>;
 				picture?: Picture;
@@ -120,7 +124,7 @@
 			<div class="flex flex-col ml-auto items-end justify-center">
 				<PriceTag
 					class="text-2xl truncate"
-					amount={item.quantity *
+					amount={Math.max(item.quantity - (item.freeQuantity ?? 0), 0) *
 						(item.booking && item.product.bookingSpec
 							? differenceInMinutes(item.booking.end, item.booking.start) /
 							  item.product.bookingSpec.slotMinutes
@@ -134,7 +138,7 @@
 				{#if item.currencySnapshot.secondary}
 					<PriceTag
 						class="text-2xl truncate"
-						amount={item.quantity *
+						amount={Math.max(item.quantity - (item.freeQuantity ?? 0), 0) *
 							(item.currencySnapshot.secondary.customPrice?.amount ??
 								item.currencySnapshot.secondary.price.amount) *
 							(item.discountPercentage ? (100 - item.discountPercentage) / 100 : 1)}
diff --git a/src/lib/server/cart.ts b/src/lib/server/cart.ts
index f8a17f025..e4a81eb6f 100644
--- a/src/lib/server/cart.ts
+++ b/src/lib/server/cart.ts
@@ -64,6 +64,7 @@ export async function addToCartInDb(
 		customPrice?: { amount: number; currency: Currency };
 		deposit?: boolean;
 		chosenVariations?: Record<string, string>;
+		freeQuantity?: number;
 		booking?: {
 			time: Date;
 			durationMinutes: number;
@@ -203,6 +204,7 @@ export async function addToCartInDb(
 			...(params.chosenVariations && {
 				chosenVariations: params.chosenVariations
 			}),
+			...(params.freeQuantity && { freeQuantity: params.freeQuantity }),
 			reservedUntil: addMinutes(new Date(), runtimeConfig.reserveStockInMinutes),
 			...(depositPercentage && { depositPercentage }),
 			...(params.booking &&
diff --git a/src/lib/server/migrations.ts b/src/lib/server/migrations.ts
index fe50643e9..0352dae1e 100644
--- a/src/lib/server/migrations.ts
+++ b/src/lib/server/migrations.ts
@@ -498,6 +498,17 @@ const migrations = [
 				{ session }
 			);
 		}
+	},
+	{
+		_id: new ObjectId('680751f0e6ba7ca6454423d0'),
+		name: 'Add mode to existing discounts',
+		run: async (session: ClientSession) => {
+			await collections.discounts.updateMany(
+				{ percentage: { $exists: true }, mode: { $exists: false } },
+				{ $set: { mode: 'percentage' } },
+				{ session }
+			);
+		}
 	}
 ];
 
diff --git a/src/lib/server/orders.ts b/src/lib/server/orders.ts
index 78aee1932..1fa317171 100644
--- a/src/lib/server/orders.ts
+++ b/src/lib/server/orders.ts
@@ -395,6 +395,33 @@ export async function onOrderPaymentFailed(
 		},
 		{ returnDocument: 'after', session: opts?.session }
 	);
+	if (
+		ret.value &&
+		order.status !== ret.value.status &&
+		(ret.value.status === 'canceled' ||
+			ret.value.status === 'expired' ||
+			ret.value.status === 'failed')
+	) {
+		for (const item of order.items) {
+			if (item.freeProductSources?.length) {
+				for (const source of item.freeProductSources) {
+					await collections.paidSubscriptions.updateOne(
+						{ _id: source.subscriptionId },
+						{
+							$inc: {
+								[`freeProductsById.${item.product._id}.used`]: -source.quantity,
+								[`freeProductsById.${item.product._id}.available`]: source.quantity
+							},
+							$set: {
+								updatedAt: new Date()
+							}
+						},
+						{ session: opts?.session }
+					);
+				}
+			}
+		}
+	}
 	if (!ret.value) {
 		throw new Error('Failed to update order');
 	}
@@ -478,6 +505,8 @@ export async function createOrder(
 		chosenVariations?: Record<string, string>;
 		depositPercentage?: number;
 		discountPercentage?: number;
+		freeQuantity?: number;
+		freeProductSources?: { subscriptionId: string; quantity: number }[];
 		booking?: Cart['items'][0]['booking'] & { _id: ObjectId };
 	}>,
 	// null when point of sale want to use multiple payment methods
@@ -518,6 +547,7 @@ export async function createOrder(
 		};
 		onLocation?: boolean;
 		paymentTimeOut?: number;
+		session?: ClientSession;
 	}
 ): Promise<Order['_id']> {
 	const npubAddress = params.notifications?.paymentStatus?.npub;
@@ -637,7 +667,7 @@ export async function createOrder(
 		? await collections.discounts
 				.aggregate<{
 					_id: Product['_id'] | null;
-					discountPercent: Discount['percentage'];
+					discountPercent: number;
 					subscriptionIds: Discount['subscriptionIds'];
 				}>([
 					{
@@ -650,6 +680,7 @@ export async function createOrder(
 							beginsAt: {
 								$lt: new Date()
 							},
+							mode: 'percentage',
 							$and: [
 								{
 									$or: [
@@ -713,6 +744,49 @@ export async function createOrder(
 
 	for (const item of items) {
 		item.discountPercentage ??= discountByProductId.get(item.product._id) ?? wholeDiscount;
+		if (item.freeQuantity) {
+			let quantityToConsume = Math.min(item.quantity, item.freeQuantity);
+			const freeProductSubscriptions = await collections.paidSubscriptions
+				.find({
+					...userQuery(params.user),
+					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
+					paidUntil: { $gt: new Date() }
+				})
+				.sort({ createdAt: 1 })
+				.toArray();
+			const usedSources: { subscriptionId: string; quantity: number }[] = [];
+
+			for (const sub of freeProductSubscriptions) {
+				if (quantityToConsume <= 0) {
+					break;
+				}
+
+				const subAvailable = sub.freeProductsById?.[item.product._id]?.available ?? 0;
+				if (subAvailable <= 0) {
+					continue;
+				}
+
+				const toUse = Math.min(quantityToConsume, subAvailable);
+				quantityToConsume -= toUse;
+
+				await collections.paidSubscriptions.updateOne(
+					{ _id: sub._id },
+					{
+						$inc: {
+							[`freeProductsById.${item.product._id}.used`]: toUse,
+							[`freeProductsById.${item.product._id}.available`]: -toUse
+						},
+						$set: {
+							updatedAt: new Date()
+						}
+					},
+					{ session: params.session }
+				);
+
+				usedSources.push({ subscriptionId: sub._id, quantity: toUse });
+			}
+			item.freeProductSources = usedSources;
+		}
 	}
 
 	const priceInfo = computePriceInfo(items, {
@@ -1087,6 +1161,8 @@ export async function createOrder(
 					chosenVariations: item.chosenVariations,
 					depositPercentage: item.depositPercentage,
 					discountPercentage: item.discountPercentage,
+					freeQuantity: item.freeQuantity,
+					freeProductSources: item.freeProductSources,
 					...(item.product.bookingSpec &&
 						item.booking && {
 							booking: { start: item.booking.start, end: item.booking.end, _id: item.booking._id }
@@ -1898,49 +1974,102 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	await collections.scheduleEvents.updateMany(
-		{ orderId: order._id },
-		{ $set: { status: 'confirmed' } },
-		{ session }
-	);
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
+		await collections.scheduleEvents.updateMany(
+			{ orderId: order._id },
+			{ $set: { status: 'confirmed' } },
+			{ session }
 		);
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				let updatedFreeProductsById: Record<
+					string,
+					{ total: number; available: number; used: number }
+				> = {};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						updatedFreeProductsById = { ...existingSubscription.freeProductsById };
+
+						for (const [productId, newQuantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!updatedFreeProductsById[productId]) {
+								updatedFreeProductsById[productId] = { total: 0, available: 0, used: 0 };
+							}
+							updatedFreeProductsById[productId].total += newQuantity;
+							updatedFreeProductsById[productId].available += newQuantity;
+						}
+					}
+				}
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: [],
+							...(Object.keys(updatedFreeProductsById).length !== 0 && {
+								freeProductsById: updatedFreeProductsById
+							})
+						},
+						$unset: { cancelledAt: 1 }
+					},
+					{ session }
+				);
+
+				if (!result.modifiedCount) {
+					throw new Error('Failed to update subscription');
+				}
+			} else {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
+				await collections.paidSubscriptions.insertOne(
+					{
+						_id: crypto.randomUUID(),
+						number: await generateSubscriptionNumber(),
+						user: order.user,
+						productId: subscription.product._id,
+						paidUntil: add(new Date(), { [`${runtimeConfig.subscriptionDuration}s`]: 1 }),
+						createdAt: new Date(),
+						updatedAt: new Date(),
+						notifications: [],
+						...(Object.keys(freeProductsById).length !== 0 && { freeProductsById })
+					},
+					{ session }
+				);
 			}
-		} else {
-			await collections.paidSubscriptions.insertOne(
-				{
-					_id: crypto.randomUUID(),
-					number: await generateSubscriptionNumber(),
-					user: order.user,
-					productId: subscription.product._id,
-					paidUntil: add(new Date(), { [`${runtimeConfig.subscriptionDuration}s`]: 1 }),
-					createdAt: new Date(),
-					updatedAt: new Date(),
-					notifications: []
-				},
-				{ session }
-			);
 		}
 	}
 	//#endregion
diff --git a/src/lib/translations/en.json b/src/lib/translations/en.json
index b2b668de0..4152d5be3 100644
--- a/src/lib/translations/en.json
+++ b/src/lib/translations/en.json
@@ -665,6 +665,8 @@
 		},
 		"discountBanner": "{discountPercent}% off for {timespan}",
 		"discountBannerNoTime": "{discountPercent}% off",
+		"freeProductDiscountText": "You have {available} free products",
+		"freeProductDiscountTextSingular": "You have {available} free product",
 		"freeWithSub": "This product is available for free with your monthly subscription",
 		"freeWithTitle": "Free with \"xxxxx",
 		"horsTaxeShort": "HT",
diff --git a/src/lib/translations/es-sv.json b/src/lib/translations/es-sv.json
index ec4f8226c..0b7559319 100644
--- a/src/lib/translations/es-sv.json
+++ b/src/lib/translations/es-sv.json
@@ -665,6 +665,8 @@
 		},
 		"discountBanner": "{discountPercent}% de descuento por {timespan}",
 		"discountBannerNoTime": "{discountPercent}% de descuento",
+		"freeProductDiscountText": "Tienes {available} productos gratuitos",
+		"freeProductDiscountTextSingular": "Tienes {available} producto gratis",
 		"freeWithSub": "Este producto es gratis con una suscripción mensual",
 		"freeWithTitle": "Gratis con \"xxxxx",
 		"horsTaxeShort": "sin IVA",
diff --git a/src/lib/translations/fr.json b/src/lib/translations/fr.json
index 1dc3ba400..a0c8c595d 100644
--- a/src/lib/translations/fr.json
+++ b/src/lib/translations/fr.json
@@ -665,6 +665,8 @@
 		},
 		"discountBanner": "{discountPercent}% de réduction pendant {timespan}",
 		"discountBannerNoTime": "{discountPercent}% de réduction",
+		"freeProductDiscountText": "Vous avez {available} produits gratuits",
+		"freeProductDiscountTextSingular": "Vous avez {available} produit gratuit",
 		"freeWithSub": "Ce produit est disponible gratuitement avec votre abonnement mensuel",
 		"freeWithTitle": "Gratuit avec \"xxxxx",
 		"horsTaxeShort": "HT",
diff --git a/src/lib/translations/it.json b/src/lib/translations/it.json
index a28094d4a..fffefb4b1 100644
--- a/src/lib/translations/it.json
+++ b/src/lib/translations/it.json
@@ -665,6 +665,8 @@
 		},
 		"discountBanner": "{discountPercent}% di sconto per {timespan}",
 		"discountBannerNoTime": "{discountPercent}% di sconto",
+		"freeProductDiscountText": "Hai {available} prodotti gratuiti",
+		"freeProductDiscountTextSingular": "Hai {available} prodotto gratuito",
 		"freeWithSub": "Questo prodotto è disponibile gratuitamente con il tuo abbonamento mensile",
 		"freeWithTitle": "Gratuito con \"xxxxx",
 		"horsTaxeShort": "HT",
diff --git a/src/lib/translations/nl.json b/src/lib/translations/nl.json
index a50ed9237..c4da64b1f 100644
--- a/src/lib/translations/nl.json
+++ b/src/lib/translations/nl.json
@@ -665,6 +665,8 @@
 		},
 		"discountBanner": "{discountPercent}% van voor {timespan}",
 		"discountBannerNoTime": "{discountPercent}% van",
+		"freeProductDiscountText": "U hebt {available} gratis producten",
+		"freeProductDiscountTextSingular": "U hebt {available} gratis product",
 		"freeWithSub": "Dit product is gratis verkrijgbaar bij uw maandabonnement",
 		"freeWithTitle": "Gratis met \"xxxxx",
 		"horsTaxeShort": "HT",
diff --git a/src/lib/types/Cart.ts b/src/lib/types/Cart.ts
index 5ba39ec96..4bc5a565d 100644
--- a/src/lib/types/Cart.ts
+++ b/src/lib/types/Cart.ts
@@ -39,6 +39,7 @@ export interface Cart extends Timestamps {
 		depositPercentage?: number;
 		internalNote?: { value: string; updatedAt: Date; updatedById?: User['_id'] };
 		chosenVariations?: Record<string, string>;
+		freeQuantity?: number;
 	}>;
 }
 
@@ -120,6 +121,8 @@ export function computePriceInfo(
 		customPrice?: Price;
 		depositPercentage?: number;
 		discountPercentage?: number;
+		freeQuantity?: number;
+		freeProductSources?: { subscriptionId: string; quantity: number }[];
 		booking?: {
 			start: Date;
 			end: Date;
@@ -188,7 +191,7 @@ export function computePriceInfo(
 		const currency = (item.customPrice || item.product.price).currency;
 		const price = fixCurrencyRounding(
 			(item.customPrice || item.product.price).amount *
-				item.quantity *
+				Math.max(item.quantity - (item.freeQuantity ?? 0), 0) *
 				(item.booking && item.product.bookingSpec?.slotMinutes
 					? differenceInMinutes(item.booking.end, item.booking.start) /
 					  item.product.bookingSpec.slotMinutes
@@ -218,7 +221,7 @@ export function computePriceInfo(
 			currency: (item.customPrice || item.product.price).currency,
 			amount:
 				(((item.customPrice || item.product.price).amount *
-					item.quantity *
+					Math.max(item.quantity - (item.freeQuantity ?? 0), 0) *
 					(item.booking && item.product.bookingSpec?.slotMinutes
 						? differenceInMinutes(item.booking.end, item.booking.start) /
 						  item.product.bookingSpec.slotMinutes
@@ -248,7 +251,7 @@ export function computePriceInfo(
 			currency: (item.customPrice || item.product.price).currency,
 			amount:
 				(item.customPrice || item.product.price).amount *
-				item.quantity *
+				Math.max(item.quantity - (item.freeQuantity ?? 0), 0) *
 				(item.booking && item.product.bookingSpec?.slotMinutes
 					? differenceInMinutes(item.booking.end, item.booking.start) /
 					  item.product.bookingSpec.slotMinutes
diff --git a/src/lib/types/Discount.ts b/src/lib/types/Discount.ts
index 52871cf8e..026481aac 100644
--- a/src/lib/types/Discount.ts
+++ b/src/lib/types/Discount.ts
@@ -1,14 +1,22 @@
 import type { Product } from './Product';
 import type { Timestamps } from './Timestamps';
 
-export interface Discount extends Timestamps {
+export type Discount = Timestamps & {
 	_id: string;
 	name: string;
 	subscriptionIds: string[];
 	/* If empty, works on all products */
 	productIds: Product['_id'][];
-	percentage: number;
 	wholeCatalog: boolean;
 	beginsAt: Date;
 	endsAt: Date | null;
-}
+} & (
+		| {
+				mode: 'percentage';
+				percentage: number;
+		  }
+		| {
+				mode: 'freeProducts';
+				quantityPerProduct: Record<string, number>;
+		  }
+	);
diff --git a/src/lib/types/Order.ts b/src/lib/types/Order.ts
index 4c98abe53..b7e51ceda 100644
--- a/src/lib/types/Order.ts
+++ b/src/lib/types/Order.ts
@@ -156,6 +156,8 @@ export interface Order extends Timestamps {
 		customPrice?: { amount: number; currency: Currency };
 		chosenVariations?: Record<string, string>;
 		depositPercentage?: number;
+		freeQuantity?: number;
+		freeProductSources?: { subscriptionId: string; quantity: number }[];
 		/**
 		 * This is the discount percentage AFTER `currencySnaphot`. To calculate the paid price, multiply
 		 * the price in {@link currencySnaphot} by (1 - discountPercentage / 100).
diff --git a/src/lib/types/PaidSubscription.ts b/src/lib/types/PaidSubscription.ts
index 41251875f..565fcf3ff 100644
--- a/src/lib/types/PaidSubscription.ts
+++ b/src/lib/types/PaidSubscription.ts
@@ -26,4 +26,5 @@ export interface PaidSubscription extends Timestamps {
 	}>;
 
 	cancelledAt?: Date;
+	freeProductsById?: Record<string, { available: number; total: number; used: number }>;
 }
diff --git a/src/routes/(app)/+layout.server.ts b/src/routes/(app)/+layout.server.ts
index 8c927d100..bda79a8fd 100644
--- a/src/routes/(app)/+layout.server.ts
+++ b/src/routes/(app)/+layout.server.ts
@@ -10,7 +10,6 @@ import type { DigitalFile } from '$lib/types/DigitalFile';
 import { userQuery } from '$lib/server/user.js';
 import { userIdentifier } from '$lib/server/user.js';
 import type { CMSPage } from '$lib/types/CmsPage.js';
-import type { Discount } from '$lib/types/Discount';
 import type { Product } from '$lib/types/Product';
 import { UrlDependency } from '$lib/types/UrlDependency';
 import type { VatProfile } from '$lib/types/VatProfile.js';
@@ -148,7 +147,7 @@ export async function load(params) {
 		? await collections.discounts
 				.aggregate<{
 					_id: Product['_id'] | null;
-					discountPercent: Discount['percentage'];
+					discountPercent: number;
 				}>([
 					{
 						$match: {
@@ -160,6 +159,7 @@ export async function load(params) {
 							beginsAt: {
 								$lt: new Date()
 							},
+							mode: 'percentage',
 							$and: [
 								{
 									$or: [
@@ -236,6 +236,7 @@ export async function load(params) {
 				quantity: item.quantity,
 				...(item.customPrice && { customPrice: item.customPrice }),
 				...(item.chosenVariations && { chosenVariations: item.chosenVariations }),
+				...(item.freeQuantity && { freeQuantity: item.freeQuantity }),
 				depositPercentage: item.depositPercentage,
 				internalNote:
 					item.internalNote && params.locals.user?.roleId === POS_ROLE_ID
diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte
index 3ca98b813..7fb685768 100644
--- a/src/routes/(app)/+layout.svelte
+++ b/src/routes/(app)/+layout.svelte
@@ -339,10 +339,11 @@
 													</div>
 												{/if}
 											</div>
+
 											<div class="flex flex-col items-end gap-[6px] ml-auto">
 												<PriceTag
 													class="text-base"
-													amount={(item.quantity *
+													amount={(Math.max(item.quantity - (item.freeQuantity ?? 0), 0) *
 														price.amount *
 														(item.booking && item.product.bookingSpec
 															? differenceInMinutes(item.booking.end, item.booking.start) /
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.server.ts b/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.server.ts
index 3fcfc0bc6..5b48320a9 100644
--- a/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.server.ts
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.server.ts
@@ -45,50 +45,99 @@ export const actions = {
 		if (!discount) {
 			throw error(404, 'discount not found');
 		}
+		const formData = await request.formData();
 
-		const data = await request.formData();
-
-		const { name, subscriptionIds, productIds, wholeCatalog, percentage, beginsAt, endsAt } = z
-			.object({
-				name: z.string().min(1).max(MAX_NAME_LIMIT),
-				productIds: z.string().array(),
-				subscriptionIds: z.string().array(),
-				percentage: z.string().regex(/^\d+(\.\d+)?$/),
-				wholeCatalog: z.boolean({ coerce: true }).default(false),
-				beginsAt: z.date({ coerce: true }),
-				endsAt: z.date({ coerce: true }).optional()
-			})
-			.parse({
-				name: data.get('name'),
-				subscriptionIds: JSON.parse(String(data.get('subscriptionIds'))).map(
-					(x: { value: string }) => x.value
-				),
-				productIds: JSON.parse(String(data.get('productIds'))).map(
-					(x: { value: string }) => x.value
-				),
-				wholeCatalog: data.get('wholeCatalog'),
-				percentage: data.get('percentage'),
-				beginsAt: data.get('beginsAt'),
-				endsAt: data.get('endsAt') || undefined
-			});
-
-		await collections.discounts.updateOne(
-			{
-				_id: discount._id
-			},
-			{
-				$set: {
-					name,
-					percentage: Number(percentage),
-					subscriptionIds,
-					wholeCatalog,
-					productIds,
-					beginsAt,
-					endsAt: endsAt || null,
-					updatedAt: new Date()
+		const quantityPerProduct: Record<string, number> = {};
+		for (const [key, value] of formData.entries()) {
+			const match = key.match(/^quantityPerProduct\[(.+?)\]$/);
+			if (match) {
+				quantityPerProduct[match[1]] = Number(value);
+			}
+		}
+
+		const base = z.object({
+			name: z.string().min(1).max(MAX_NAME_LIMIT),
+			subscriptionIds: z.string().array().min(1),
+			productIds: z.string().array(),
+			wholeCatalog: z.boolean({ coerce: true }).default(false),
+			beginsAt: z.date({ coerce: true }),
+			endsAt: z.date({ coerce: true }).nullable()
+		});
+
+		const percentageSchema = z.object({
+			percentage: z
+				.string()
+				.regex(/^\d+(\.\d+)?$/)
+				.transform((val) => Number(val))
+		});
+
+		const freeProductsSchema = z.object({
+			quantityPerProduct: z.record(z.string(), z.number().min(0).max(100)).optional()
+		});
+
+		const baseData = base.parse({
+			name: formData.get('name'),
+			subscriptionIds: JSON.parse(String(formData.get('subscriptionIds'))).map(
+				(x: { value: string }) => x.value
+			),
+			productIds: JSON.parse(String(formData.get('productIds') ?? '[]')).map(
+				(x: { value: string }) => x.value
+			),
+			wholeCatalog: formData.get('wholeCatalog'),
+			beginsAt: formData.get('beginsAt'),
+			endsAt: formData.get('endsAt') || null
+		});
+
+		const timestamp = { createdAt: new Date(), updatedAt: new Date() };
+
+		if (discount.mode === 'percentage') {
+			const parsed = percentageSchema.safeParse({ percentage: formData.get('percentage') });
+
+			if (!parsed.success || parsed.data.percentage < 0 || isNaN(parsed.data.percentage)) {
+				throw error(400, 'Invalid or missing percentage');
+			}
+
+			const percentageDiscount = {
+				...baseData,
+				...timestamp,
+				percentage: parsed.data.percentage
+			};
+			await collections.discounts.updateOne(
+				{
+					_id: discount._id
+				},
+				{
+					$set: percentageDiscount
 				}
+			);
+		}
+
+		if (discount.mode === 'freeProducts') {
+			const parsed = freeProductsSchema.safeParse({ quantityPerProduct });
+
+			if (
+				!parsed.success ||
+				!parsed.data.quantityPerProduct ||
+				Object.keys(parsed.data.quantityPerProduct).length === 0
+			) {
+				throw error(400, 'Invalid or missing quantityPerProduct');
 			}
-		);
+
+			const freeProductsDiscount = {
+				...baseData,
+				...timestamp,
+				quantityPerProduct: parsed.data.quantityPerProduct
+			};
+
+			await collections.discounts.updateOne(
+				{
+					_id: discount._id
+				},
+				{
+					$set: freeProductsDiscount
+				}
+			);
+		}
 	},
 
 	delete: async function ({ params }) {
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.svelte b/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.svelte
index c0e54a1db..f85a6b76c 100644
--- a/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.svelte
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.svelte
@@ -1,5 +1,6 @@
 <script lang="ts">
 	import { MAX_NAME_LIMIT } from '$lib/types/Product';
+	import { upperFirst } from '$lib/utils/upperFirst.js';
 	import { MultiSelect } from 'svelte-multiselect';
 
 	export let data;
@@ -10,7 +11,12 @@
 	let availableProductList = data.products;
 	let subscriptions = data.subscriptions;
 	let wholeCatalog = data.discount.wholeCatalog;
-
+	let mode = data.discount.mode;
+	let productFree: string[] = [];
+	if (data.discount.mode === 'freeProducts') {
+		productFree = Object.keys(data.discount.quantityPerProduct ?? {});
+	}
+	let productFreeLine = productFree.length;
 	function checkForm(event: SubmitEvent) {
 		if (endsAt && endsAt < beginsAt) {
 			endsAtElement.setCustomValidity('End date must be after beginning date');
@@ -49,20 +55,29 @@
 			required
 		/>
 	</label>
-
 	<label class="form-label">
-		Discount percentage
-		<input
-			class="form-input"
-			type="number"
-			min="1"
-			max="100"
-			name="percentage"
-			value={data.discount.percentage}
-			placeholder="discount percentage"
-			required
-		/>
+		Mode
+		<select name="mode" class="form-input" bind:value={mode} disabled>
+			{#each ['percentage', 'freeProducts'] as modeDiscount}
+				<option value={modeDiscount}>{upperFirst(modeDiscount)}</option>
+			{/each}
+		</select>
 	</label>
+	{#if mode === 'percentage'}
+		<label class="form-label">
+			Discount percentage
+			<input
+				class="form-input"
+				type="number"
+				min="1"
+				max="100"
+				name="percentage"
+				value={data.discount.mode === 'percentage' ? data.discount.percentage : 0}
+				placeholder="discount percentage"
+				required
+			/>
+		</label>
+	{/if}
 
 	<div class="flex flex-wrap gap-4">
 		<label class="form-label">
@@ -99,26 +114,83 @@
 			}))}
 		/>
 	</label>
-
-	<label class="checkbox-label">
-		<input type="checkbox" name="wholeCatalog" class="form-checkbox" bind:checked={wholeCatalog} />
-		The discount applies to the whole catalog (except free, subscription & PWYW products)
-	</label>
-	{#if !wholeCatalog}
-		<!-- svelte-ignore a11y-label-has-associated-control -->
-		<label class="form-label"
-			>Products
-			<MultiSelect
-				name="productIds"
-				options={availableProductList.map((p) => ({ label: p.name, value: p._id }))}
-				selected={data.discount.productIds.map((p) => ({
-					value: p,
-					label: availableProductList.find((p2) => p2._id === p)?.name ?? p
-				}))}
+	{#if mode === 'percentage'}
+		<label class="checkbox-label">
+			<input
+				type="checkbox"
+				name="wholeCatalog"
+				class="form-checkbox"
+				bind:checked={wholeCatalog}
 			/>
+			The discount applies to the whole catalog (except free, subscription & PWYW products)
 		</label>
+		{#if !wholeCatalog}
+			<!-- svelte-ignore a11y-label-has-associated-control -->
+			<label class="form-label"
+				>Products
+				<MultiSelect
+					name="productIds"
+					options={availableProductList.map((p) => ({ label: p.name, value: p._id }))}
+					selected={data.discount.productIds.map((p) => ({
+						value: p,
+						label: availableProductList.find((p2) => p2._id === p)?.name ?? p
+					}))}
+				/>
+			</label>
+		{/if}
 	{/if}
 
+	{#if mode === 'freeProducts'}
+		<table class="justify-between gap-4">
+			<thead>
+				<tr>
+					<td>Product slug</td>
+					<td>Free copies quantity</td>
+					<td></td>
+				</tr>
+			</thead>
+			{#each [...productFree, ...Array(productFreeLine).fill( { productId: '', quantity: 0 } )].slice(0, productFreeLine) as product, i}
+				<tbody>
+					<tr>
+						<td>
+							<label class="form-label m-1">
+								<select class="form-input" bind:value={productFree[i]}>
+									{#each availableProductList as prod}
+										<option value={prod._id} selected={prod._id === product}>{prod._id}</option>
+									{/each}
+								</select>
+							</label>
+						</td>
+						<td>
+							<label class="form-label m-1">
+								<input
+									type="number"
+									name="quantityPerProduct[{productFree[i]}]"
+									class="form-input"
+									value={data.discount.mode === 'freeProducts'
+										? data.discount.quantityPerProduct?.[product] ?? 0
+										: 0}
+								/>
+							</label>
+						</td>
+						<td>
+							<button
+								type="button"
+								class="self-start"
+								on:click={() => {
+									(productFree = productFree.filter((prod) => prod !== product)),
+										(productFreeLine -= 1);
+								}}>🗑️</button
+							>
+						</td>
+					</tr>
+				</tbody>
+			{/each}
+		</table>
+		<button class="btn btn-gray self-start" on:click={() => (productFreeLine += 1)} type="button"
+			>Add product
+		</button>
+	{/if}
 	<div class="flex flex-row justify-between gap-2">
 		<input type="submit" class="btn btn-blue text-white" formaction="?/update" value="Update" />
 		<a href="/discounts/{data.discount._id}" class="btn btn-gray">View</a>
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts b/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts
index 1242bae1b..e733d3567 100644
--- a/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts
@@ -1,10 +1,11 @@
 import { collections } from '$lib/server/database';
 import type { Actions } from './$types';
-import { redirect } from '@sveltejs/kit';
+import { error, redirect } from '@sveltejs/kit';
 import { z } from 'zod';
 import { MAX_NAME_LIMIT, type Product } from '$lib/types/Product';
 import { generateId } from '$lib/utils/generateId';
 import { adminPrefix } from '$lib/server/admin';
+import { Discount } from '$lib/types/Discount';
 
 export const load = async () => {
 	const subscriptions = await collections.products
@@ -29,46 +30,94 @@ export const load = async () => {
 
 export const actions: Actions = {
 	default: async function ({ request }) {
-		const data = await request.formData();
-
-		const { name, percentage, subscriptionIds, productIds, wholeCatalog, beginsAt, endsAt } = z
-			.object({
-				name: z.string().min(1).max(MAX_NAME_LIMIT),
-				productIds: z.string().array(),
-				subscriptionIds: z.string().array().min(1),
-				percentage: z.string().regex(/^\d+(\.\d+)?$/),
-				wholeCatalog: z.boolean({ coerce: true }).default(false),
-				beginsAt: z.date({ coerce: true }),
-				endsAt: z.date({ coerce: true }).optional()
-			})
-			.parse({
-				name: data.get('name'),
-				subscriptionIds: JSON.parse(String(data.get('subscriptionIds'))).map(
-					(x: { value: string }) => x.value
-				),
-				productIds: JSON.parse(String(data.get('productIds') ?? '[]')).map(
-					(x: { value: string }) => x.value
-				),
-				wholeCatalog: data.get('wholeCatalog'),
-				percentage: data.get('percentage'),
-				beginsAt: data.get('beginsAt'),
-				endsAt: data.get('endsAt') || undefined
-			});
-
-		const slug = generateId(name, true);
-		await collections.discounts.insertOne({
-			_id: slug,
-			name,
-			productIds: productIds,
-			subscriptionIds: subscriptionIds,
-			wholeCatalog,
-			percentage: Number(percentage),
-			beginsAt,
-			endsAt: endsAt || null,
-			createdAt: new Date(),
-			updatedAt: new Date()
+		const formData = await request.formData();
+
+		const quantityPerProduct: Record<string, number> = {};
+		for (const [key, value] of formData.entries()) {
+			const match = key.match(/^quantityPerProduct\[(.+?)\]$/);
+			if (match) {
+				quantityPerProduct[match[1]] = Number(value);
+			}
+		}
+
+		const base = z.object({
+			name: z.string().min(1).max(MAX_NAME_LIMIT),
+			subscriptionIds: z.string().array().min(1),
+			productIds: z.string().array(),
+			wholeCatalog: z.boolean({ coerce: true }).default(false),
+			beginsAt: z.date({ coerce: true }),
+			endsAt: z.date({ coerce: true }).nullable(),
+			mode: z.enum(['percentage', 'freeProducts'])
 		});
 
+		const percentageSchema = z.object({
+			percentage: z
+				.string()
+				.regex(/^\d+(\.\d+)?$/)
+				.transform((val) => Number(val))
+		});
+
+		const freeProductsSchema = z.object({
+			quantityPerProduct: z.record(z.string(), z.number().min(0).max(100)).optional()
+		});
+
+		const baseData = base.parse({
+			name: formData.get('name'),
+			subscriptionIds: JSON.parse(String(formData.get('subscriptionIds'))).map(
+				(x: { value: string }) => x.value
+			),
+			productIds: JSON.parse(String(formData.get('productIds') ?? '[]')).map(
+				(x: { value: string }) => x.value
+			),
+			wholeCatalog: formData.get('wholeCatalog'),
+			beginsAt: formData.get('beginsAt'),
+			endsAt: formData.get('endsAt') || null,
+			mode: formData.get('mode')
+		});
+
+		const slug = generateId(baseData.name, true);
+		const timestamp = { createdAt: new Date(), updatedAt: new Date() };
+
+		if (baseData.mode === 'percentage') {
+			const parsed = percentageSchema.safeParse({ percentage: formData.get('percentage') });
+
+			if (!parsed.success || parsed.data.percentage < 0 || isNaN(parsed.data.percentage)) {
+				throw error(400, 'Invalid or missing percentage');
+			}
+
+			const percentageDiscount: Extract<Discount, { mode: 'percentage' }> = {
+				_id: slug,
+				...baseData,
+				...timestamp,
+				mode: 'percentage',
+				percentage: parsed.data.percentage
+			};
+
+			await collections.discounts.insertOne(percentageDiscount);
+		}
+
+		if (baseData.mode === 'freeProducts') {
+			const parsed = freeProductsSchema.safeParse({ quantityPerProduct });
+
+			if (
+				!parsed.success ||
+				!parsed.data.quantityPerProduct ||
+				Object.keys(parsed.data.quantityPerProduct).length === 0
+			) {
+				throw error(400, 'Invalid or missing quantityPerProduct');
+			}
+
+			const freeProductsDiscount: Extract<Discount, { mode: 'freeProducts' }> = {
+				_id: slug,
+				...baseData,
+				...timestamp,
+				mode: 'freeProducts',
+				quantityPerProduct: parsed.data.quantityPerProduct
+			};
+
+			await collections.discounts.insertOne(freeProductsDiscount);
+		}
+
 		throw redirect(303, `${adminPrefix()}/discount/${slug}`);
 	}
 };
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.svelte b/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.svelte
index c6674e288..4a9f4c634 100644
--- a/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.svelte
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.svelte
@@ -1,6 +1,7 @@
 <script lang="ts">
 	import { enhance } from '$app/forms';
 	import { MAX_NAME_LIMIT } from '$lib/types/Product';
+	import { upperFirst } from '$lib/utils/upperFirst.js';
 	import { addDays } from 'date-fns';
 	import { MultiSelect } from 'svelte-multiselect';
 
@@ -11,7 +12,9 @@
 	let availableProductList = data.products;
 	let subscriptions = data.subscriptions;
 	let wholeCatalog = false;
-
+	let mode = 'percentage';
+	let productFreeLine = 2;
+	let productFree: string[] = [];
 	function checkForm(event: SubmitEvent) {
 		if (endsAt && endsAt < beginsAt) {
 			endsAtElement.setCustomValidity('End date must be after beginning date');
@@ -38,19 +41,28 @@
 			required
 		/>
 	</label>
-
 	<label class="form-label">
-		Discount percentage
-		<input
-			class="form-input"
-			type="number"
-			min="1"
-			max="100"
-			name="percentage"
-			placeholder="discount percentage"
-			required
-		/>
+		Mode
+		<select name="mode" class="form-input" bind:value={mode}>
+			{#each ['percentage', 'freeProducts'] as modeDiscount}
+				<option value={modeDiscount}>{upperFirst(modeDiscount)}</option>
+			{/each}
+		</select>
 	</label>
+	{#if mode === 'percentage'}
+		<label class="form-label">
+			Discount percentage
+			<input
+				class="form-input"
+				type="number"
+				min="1"
+				max="100"
+				name="percentage"
+				placeholder="discount percentage"
+				required
+			/>
+		</label>
+	{/if}
 
 	<div class="flex flex-wrap gap-4">
 		<label class="form-label">
@@ -81,22 +93,79 @@
 		<MultiSelect
 			name="subscriptionIds"
 			options={subscriptions.map((p) => ({ label: p.name, value: p._id }))}
+			required
 		/>
 	</label>
-
-	<label class="checkbox-label">
-		<input type="checkbox" name="wholeCatalog" class="form-checkbox" bind:checked={wholeCatalog} />
-		The discount applies to the whole catalog (except free, subscription & PWYW products)
-	</label>
-	{#if !wholeCatalog}
-		<!-- svelte-ignore a11y-label-has-associated-control -->
-		<label class="form-label"
-			>Products
-			<MultiSelect
-				name="productIds"
-				options={availableProductList.map((p) => ({ label: p.name, value: p._id }))}
+	{#if mode === 'percentage'}
+		<label class="checkbox-label">
+			<input
+				type="checkbox"
+				name="wholeCatalog"
+				class="form-checkbox"
+				bind:checked={wholeCatalog}
 			/>
+			The discount applies to the whole catalog (except free, subscription & PWYW products)
 		</label>
+		{#if !wholeCatalog}
+			<!-- svelte-ignore a11y-label-has-associated-control -->
+			<label class="form-label"
+				>Products
+				<MultiSelect
+					name="productIds"
+					options={availableProductList.map((p) => ({ label: p.name, value: p._id }))}
+				/>
+			</label>
+		{/if}
+	{/if}
+
+	{#if mode === 'freeProducts'}
+		<table class="justify-between gap-4">
+			<thead>
+				<tr>
+					<td>Product slug</td>
+					<td>Free copies quantity</td>
+					<td></td>
+				</tr>
+			</thead>
+			{#each [...Array(productFreeLine).fill( { productId: '', quantity: 0 } )].slice(0, productFreeLine) as product, i}
+				<tbody>
+					<tr>
+						<td>
+							<label class="form-label m-1">
+								<select class="form-input" bind:value={productFree[i]}>
+									{#each availableProductList as prod}
+										<option value={prod._id}>{prod._id}</option>
+									{/each}
+								</select>
+							</label>
+						</td>
+						<td>
+							<label class="form-label m-1">
+								<input
+									type="number"
+									name="quantityPerProduct[{productFree[i]}]"
+									class="form-input"
+									value={product.quantity}
+								/>
+							</label>
+						</td>
+						<td>
+							<button
+								type="button"
+								class="self-start"
+								on:click={() => {
+									(productFree = productFree.filter((prod) => prod !== productFree[i])),
+										(productFreeLine -= 1);
+								}}>🗑️</button
+							>
+						</td>
+					</tr>
+				</tbody>
+			{/each}
+		</table>
+		<button class="btn btn-gray self-start" on:click={() => (productFreeLine += 1)} type="button"
+			>Add product
+		</button>
 	{/if}
 
 	<input type="submit" class="btn btn-blue self-start text-white" value="Submit" />
diff --git a/src/routes/(app)/admin[[hash=admin_hash]]/order/[id]/+page.server.ts b/src/routes/(app)/admin[[hash=admin_hash]]/order/[id]/+page.server.ts
index c59bc2b27..c31e9545b 100644
--- a/src/routes/(app)/admin[[hash=admin_hash]]/order/[id]/+page.server.ts
+++ b/src/routes/(app)/admin[[hash=admin_hash]]/order/[id]/+page.server.ts
@@ -110,12 +110,31 @@ export const actions = {
 				$set: {
 					status: 'canceled'
 				}
-			}
+			},
+			{ returnDocument: 'after' }
 		);
 
-		if (!order) {
+		if (!order.value) {
 			throw error(404, 'Order not found');
 		}
+		for (const item of order.value?.items) {
+			if (item.freeProductSources?.length) {
+				for (const source of item.freeProductSources) {
+					await collections.paidSubscriptions.updateOne(
+						{ _id: source.subscriptionId },
+						{
+							$inc: {
+								[`freeProductsById.${item.product._id}.used`]: -source.quantity,
+								[`freeProductsById.${item.product._id}.available`]: source.quantity
+							},
+							$set: {
+								updatedAt: new Date()
+							}
+						}
+					);
+				}
+			}
+		}
 
 		throw redirect(303, request.headers.get('referer') || `${adminPrefix()}/order`);
 	}
diff --git a/src/routes/(app)/cart/+page.svelte b/src/routes/(app)/cart/+page.svelte
index fda05b0d4..8b2f087a6 100644
--- a/src/routes/(app)/cart/+page.svelte
+++ b/src/routes/(app)/cart/+page.svelte
@@ -133,6 +133,7 @@
 			>
 				{#each items as item, i}
 					{@const price = item.customPrice || item.product.price}
+					{@const quantityToPay = Math.max(item.quantity - (item.freeQuantity ?? 0), 0)}
 					<form
 						method="POST"
 						class="contents"
@@ -233,7 +234,7 @@
 						<div class="flex flex-col items-end justify-center lg:mb-0 mb-4">
 							<div class="flex gap-2">
 								<PriceTag
-									amount={(item.quantity *
+									amount={(quantityToPay *
 										(item.booking && item.product.bookingSpec?.slotMinutes
 											? differenceInMinutes(item.booking.end, item.booking.start) /
 											  item.product.bookingSpec.slotMinutes
@@ -252,7 +253,7 @@
 								>
 								{#if item.discountPercentage}
 									<PriceTag
-										amount={((item.quantity *
+										amount={((quantityToPay *
 											(item.booking && item.product.bookingSpec?.slotMinutes
 												? differenceInMinutes(item.booking.end, item.booking.start) /
 												  item.product.bookingSpec.slotMinutes
@@ -269,7 +270,7 @@
 							</div>
 							<PriceTag
 								class="text-base truncate"
-								amount={((item.quantity *
+								amount={((quantityToPay *
 									(item.booking && item.product.bookingSpec?.slotMinutes
 										? differenceInMinutes(item.booking.end, item.booking.start) /
 										  item.product.bookingSpec.slotMinutes
diff --git a/src/routes/(app)/checkout/+page.server.ts b/src/routes/(app)/checkout/+page.server.ts
index a49975a0d..e2d5e7010 100644
--- a/src/routes/(app)/checkout/+page.server.ts
+++ b/src/routes/(app)/checkout/+page.server.ts
@@ -1,4 +1,4 @@
-import { collections } from '$lib/server/database';
+import { collections, withTransaction } from '$lib/server/database';
 import { paymentMethods } from '$lib/server/payment-methods';
 import { COUNTRY_ALPHA2S, type CountryAlpha2 } from '$lib/types/Country';
 import { error, redirect } from '@sveltejs/kit';
@@ -446,74 +446,80 @@ export const actions = {
 			);
 		}
 		rateLimit(locals.clientIp, 'email', 10, { minutes: 1 });
-
-		const orderId = await createOrder(
-			cart.items.map((item) => ({
-				quantity: item.quantity,
-				product: byId[item.productId],
-				...(item.customPrice && {
-					customPrice: { amount: item.customPrice.amount, currency: item.customPrice.currency }
-				}),
-				...(item.chosenVariations && { chosenVariations: item.chosenVariations }),
-				depositPercentage: item.depositPercentage,
-				...(item.booking && {
-					booking: {
-						...item.booking,
-						_id: new ObjectId()
-					}
-				})
-			})),
-			paymentMethod,
-			{
-				locale: locals.language,
-				user: {
-					sessionId: locals.sessionId,
-					userId: locals.user?._id,
-					userLogin: locals.user?.login,
-					userRoleId: locals.user?.roleId,
-					userAlias: locals.user?.alias
-				},
-				notifications: {
-					paymentStatus: {
-						npub: npubAddress,
-						email
-					}
-				},
-				cart,
-				shippingAddress: shippingInfo?.shipping,
-				billingAddress: billingInfo?.billing || shippingInfo?.shipping,
-				userVatCountry: vatCountry,
-				...(locals.user?.roleId === POS_ROLE_ID && isFreeVat && { reasonFreeVat }),
-				...(locals.user?.roleId === POS_ROLE_ID &&
-					discountAmount &&
-					discountType &&
-					discountJustification && {
-						discount: {
-							amount: discountAmount,
-							type: discountType,
-							justification: discountJustification
-						}
-					}),
-				...(note && { note: note.noteContent }),
-				...(agreements.allowCollectIP && { clientIp: locals.clientIp }),
-				...(locals.user?.roleId === POS_ROLE_ID &&
-					runtimeConfig.deliveryFees.allowFreeForPOS &&
-					offerDeliveryFees && { reasonOfferDeliveryFees }),
-				...(receiptNote && { receiptNote: receiptNote.receiptNoteContent }),
-				engagements: {
-					...(agreements.allowCollectIP && { acceptedIPCollect: agreements.allowCollectIP }),
-					...(agreements.teecees && { acceptedTermsOfUse: agreements.teecees }),
-					...(agreements.isOnlyDeposit && {
-						acceptedDepositConditionsAndFullPayment: agreements.isOnlyDeposit
+		let orderId = '';
+		await withTransaction(async (session) => {
+			orderId = await createOrder(
+				cart.items.map((item) => ({
+					quantity: item.quantity,
+					product: byId[item.productId],
+					...(item.freeQuantity && { freeQuantity: item.freeQuantity }),
+					...(item.customPrice && {
+						customPrice: { amount: item.customPrice.amount, currency: item.customPrice.currency }
 					}),
-					...(agreements.isVATNullForeigner && {
-						acceptedExportationAndVATObligation: agreements.isVATNullForeigner
+					...(item.chosenVariations && { chosenVariations: item.chosenVariations }),
+					depositPercentage: item.depositPercentage,
+					...(item.booking && {
+						booking: {
+							...item.booking,
+							_id: new ObjectId()
+						}
 					})
-				},
-				...(physicalFullyPaid?.onLocation && { onLocation: physicalFullyPaid.onLocation }),
-				...(desiredPayment.paymentTimeOut && { paymentTimeOut: desiredPayment.paymentTimeOut })
-			}
-		);
+				})),
+				paymentMethod,
+				{
+					locale: locals.language,
+					user: {
+						sessionId: locals.sessionId,
+						userId: locals.user?._id,
+						userLogin: locals.user?.login,
+						userRoleId: locals.user?.roleId,
+						userAlias: locals.user?.alias,
+						npub: locals.npub,
+						email: locals.email
+					},
+					notifications: {
+						paymentStatus: {
+							npub: npubAddress,
+							email
+						}
+					},
+					cart,
+					shippingAddress: shippingInfo?.shipping,
+					billingAddress: billingInfo?.billing || shippingInfo?.shipping,
+					userVatCountry: vatCountry,
+					...(locals.user?.roleId === POS_ROLE_ID && isFreeVat && { reasonFreeVat }),
+					...(locals.user?.roleId === POS_ROLE_ID &&
+						discountAmount &&
+						discountType &&
+						discountJustification && {
+							discount: {
+								amount: discountAmount,
+								type: discountType,
+								justification: discountJustification
+							}
+						}),
+					...(note && { note: note.noteContent }),
+					...(agreements.allowCollectIP && { clientIp: locals.clientIp }),
+					...(locals.user?.roleId === POS_ROLE_ID &&
+						runtimeConfig.deliveryFees.allowFreeForPOS &&
+						offerDeliveryFees && { reasonOfferDeliveryFees }),
+					...(receiptNote && { receiptNote: receiptNote.receiptNoteContent }),
+					engagements: {
+						...(agreements.allowCollectIP && { acceptedIPCollect: agreements.allowCollectIP }),
+						...(agreements.teecees && { acceptedTermsOfUse: agreements.teecees }),
+						...(agreements.isOnlyDeposit && {
+							acceptedDepositConditionsAndFullPayment: agreements.isOnlyDeposit
+						}),
+						...(agreements.isVATNullForeigner && {
+							acceptedExportationAndVATObligation: agreements.isVATNullForeigner
+						})
+					},
+					...(physicalFullyPaid?.onLocation && { onLocation: physicalFullyPaid.onLocation }),
+					...(desiredPayment.paymentTimeOut && { paymentTimeOut: desiredPayment.paymentTimeOut }),
+					session
+				}
+			);
+		});
 		const displayHeadless =
 			url.searchParams.get('display') === 'headless' ? '?display=headless' : '';
 		throw redirect(303, `/order/${orderId}${displayHeadless}`);
diff --git a/src/routes/(app)/checkout/+page.svelte b/src/routes/(app)/checkout/+page.svelte
index 22e030b80..086dfb6b9 100644
--- a/src/routes/(app)/checkout/+page.svelte
+++ b/src/routes/(app)/checkout/+page.svelte
@@ -637,6 +637,8 @@
 				</div>
 				{#each items as item}
 					{@const price = item.customPrice || item.product.price}
+					{@const quantityToPay = Math.max(item.quantity - (item.freeQuantity ?? 0), 0)}
+
 					<form
 						method="POST"
 						class="flex flex-col mt-2"
@@ -724,7 +726,7 @@
 							<div class="flex flex-col ml-auto items-end justify-center">
 								<PriceTag
 									class="text-2xl truncate"
-									amount={((item.quantity *
+									amount={((quantityToPay *
 										(item.booking && item.product.bookingSpec?.slotMinutes
 											? differenceInMinutes(item.booking.end, item.booking.start) /
 											  item.product.bookingSpec.slotMinutes
@@ -742,7 +744,7 @@
 										: ''}</PriceTag
 								>
 								<PriceTag
-									amount={((item.quantity *
+									amount={((quantityToPay *
 										(item.booking && item.product.bookingSpec?.slotMinutes
 											? differenceInMinutes(item.booking.end, item.booking.start) /
 											  item.product.bookingSpec.slotMinutes
diff --git a/src/routes/(app)/order/[id]/fetchOrderForUser.ts b/src/routes/(app)/order/[id]/fetchOrderForUser.ts
index 69a7622c8..d50adc9fb 100644
--- a/src/routes/(app)/order/[id]/fetchOrderForUser.ts
+++ b/src/routes/(app)/order/[id]/fetchOrderForUser.ts
@@ -156,6 +156,7 @@ export async function fetchOrderForUser(orderId: string, params?: { userRoleId?:
 			},
 			vatRate: item.vatRate,
 			...(item.customPrice && { customPrice: item.customPrice }),
+			...(item.freeQuantity && { freeQuantity: item.freeQuantity }),
 			picture: pictures.find((picture) => picture.productId === item.product._id),
 			digitalFiles: digitalFiles.filter(
 				(digitalFile) => digitalFile.productId === item.product._id
diff --git a/src/routes/(app)/product/[id]/+page.server.ts b/src/routes/(app)/product/[id]/+page.server.ts
index ffd755a57..a9dfe6a4a 100644
--- a/src/routes/(app)/product/[id]/+page.server.ts
+++ b/src/routes/(app)/product/[id]/+page.server.ts
@@ -11,6 +11,7 @@ import { POS_ROLE_ID } from '$lib/types/User';
 import { cmsFromContent } from '$lib/server/cms';
 import type { JsonObject } from 'type-fest';
 import { set } from '$lib/utils/set';
+import { sum } from '$lib/utils/sum';
 import { productToScheduleId, type ScheduleEvent } from '$lib/types/Schedule';
 import { subDays } from 'date-fns';
 
@@ -141,7 +142,9 @@ export const load = async ({ params, locals }) => {
 					.toArray()
 			: []
 	]);
-
+	const freeProductsAvailable = sum(
+		subscriptions.map((s) => s.freeProductsById?.[product._id]?.available ?? 0)
+	);
 	const discount = subscriptions.length
 		? await collections.discounts.findOne(
 				{
@@ -150,6 +153,7 @@ export const load = async ({ params, locals }) => {
 					beginsAt: {
 						$lt: new Date()
 					},
+					mode: 'percentage',
 					$and: [
 						{
 							$or: [
@@ -168,7 +172,6 @@ export const load = async ({ params, locals }) => {
 				}
 		  )
 		: null;
-
 	return {
 		product,
 		pictures,
@@ -189,7 +192,8 @@ export const load = async ({ params, locals }) => {
 			productCMSAfter: cmsFromContent({ content: product.contentAfter }, locals)
 		}),
 		showCheckoutButton: runtimeConfig.checkoutButtonOnProductPage,
-		websiteShortDescription: product.shortDescription
+		websiteShortDescription: product.shortDescription,
+		freeProductsAvailable
 	};
 };
 
@@ -215,6 +219,7 @@ async function addToCart({ params, request, locals }: RequestEvent) {
 		customPriceCurrency,
 		deposit,
 		chosenVariations,
+		freeQuantity,
 		time,
 		durationMinutes
 	} = z
@@ -232,6 +237,11 @@ async function addToCart({ params, request, locals }: RequestEvent) {
 			customPriceCurrency: z.enum([CURRENCIES[0], ...CURRENCIES.slice(1)]).optional(),
 			deposit: z.enum(['partial', 'full']).optional(),
 			chosenVariations: z.record(z.string(), z.string()).optional(),
+			freeQuantity: z
+				.string()
+				.regex(/^\d+(\.\d+)?$/)
+				.transform((val) => Number(val))
+				.optional(),
 			time: z.date({ coerce: true }).optional(),
 			durationMinutes: z.number({ coerce: true }).int().min(1).optional()
 		})
@@ -252,6 +262,7 @@ async function addToCart({ params, request, locals }: RequestEvent) {
 		user: userIdentifier(locals),
 		mode: 'eshop',
 		...(customPrice && { customPrice }),
+		...(freeQuantity && { freeQuantity }),
 		deposit: deposit === 'partial',
 		...(product.hasVariations && { chosenVariations }),
 		...(time && durationMinutes && product.bookingSpec
diff --git a/src/routes/(app)/product/[id]/+page.svelte b/src/routes/(app)/product/[id]/+page.svelte
index ed69ba98e..6cc376839 100644
--- a/src/routes/(app)/product/[id]/+page.svelte
+++ b/src/routes/(app)/product/[id]/+page.svelte
@@ -36,6 +36,7 @@
 	import type { Day } from '$lib/types/Schedule.js';
 	import { toZonedTime } from 'date-fns-tz';
 	import { RangeList } from '$lib/utils/range-list.js';
+	import { sum } from '$lib/utils/sum.js';
 
 	export let data;
 
@@ -253,12 +254,24 @@
 			...(data.product.hasVariations && {
 				chosenVariations: selectedVariations
 			}),
-			discountPercentage: data.discount?.percentage,
+			discountPercentage:
+				data.discount?.mode === 'percentage' ? data.discount?.percentage : undefined,
 			...(data.product.bookingSpec && {
 				priceMultiplier: durationMinutes / data.product.bookingSpec.slotMinutes
 			})
 		};
 	}
+	$: freeProductsAvailable = !data.product.standalone
+		? data.freeProductsAvailable
+		: Math.max(
+				data.freeProductsAvailable -
+					sum(
+						data.cart
+							.filter((item) => item.freeQuantity && item.product._id === data.product._id)
+							.map((val) => val.quantity)
+					),
+				0
+		  );
 
 	let PWYWInput: HTMLInputElement | null = null;
 	let acceptRestriction = data.product.hasSellDisclaimer ? false : true;
@@ -448,7 +461,7 @@
 									: 1)}
 							main
 						/>
-						{#if data.discount}
+						{#if data.discount && data.discount.mode === 'percentage'}
 							<PriceTag
 								currency={data.product.price.currency}
 								class="text-2xl lg:text-4xl truncate max-w-full"
@@ -468,14 +481,30 @@
 							(data.product.bookingSpec
 								? durationMinutes / data.product.bookingSpec.slotMinutes
 								: 1) *
-							(data.discount ? 1 - data.discount.percentage / 100 : 1)}
+							(data.discount?.mode === 'percentage' && data.discount?.percentage
+								? 1 - data.discount.percentage / 100
+								: 1)}
 						secondary
 						class="text-xl"
 					/>
 					<span class="font-semibold">{t('product.vatExcluded')}</span>
 				</div>
+				{#if freeProductsAvailable}
+					<hr class="border-gray-300" />
+					<h3 class="text-[22px]">
+						{#if freeProductsAvailable > 1}
+							{t('product.freeProductDiscountText', {
+								available: freeProductsAvailable
+							})}
+						{:else}
+							{t('product.freeProductDiscountTextSingular', {
+								available: freeProductsAvailable
+							})}
+						{/if}
+					</h3>
+				{/if}
 
-				{#if data.discount && !data.product.hideDiscountExpiration}
+				{#if data.discount && data.discount.mode === 'percentage' && !data.product.hideDiscountExpiration}
 					<hr class="border-gray-300" />
 					<h3 class="text-[22px]">
 						{#if timeDifference === null}
@@ -490,7 +519,7 @@
 						{/if}
 					</h3>
 
-					{#if data.discount.percentage === 100 && 0}
+					{#if data.discount.mode === 'percentage' && data.discount.percentage === 100 && 0}
 						<hr class="border-gray-300" />
 						<div class="border border-[#F1DA63] bg-[#FFFBD5] p-2 rounded text-base flex gap-2">
 							<IconInfo class="text-[#E4C315]" />
@@ -563,6 +592,9 @@
 						class="flex flex-col gap-2"
 					>
 						{#if canBuy}
+							{#if freeProductsAvailable}
+								<input type="hidden" name="freeQuantity" value={freeProductsAvailable} />
+							{/if}
 							{#if data.product.payWhatYouWant}
 								<hr class="border-gray-300 lg:hidden mt-4 pb-2" />
 								<input type="hidden" name="customPriceCurrency" value={PWYWCurrency} />


Reviews:
user: login: coyotte508
body: For now it doesn't seem to actually allow the user to get free products, that's fine, but please indicate this in your PR (that this part needs to be done in next PR)
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T14:50:02Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-17T20:21:21Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T12:13:28Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-18T13:57:26Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T12:11:17Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-22T23:15:22Z
user: login: coyotte508
body: ok with the two changes mentioned
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T11:43:35Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T13:42:38Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T19:34:09Z
user: login: coyotte508
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-24T19:34:46Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-29T19:44:18Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T10:17:26Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T10:44:51Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T15:44:58Z
user: login: ithiame
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T16:12:51Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-02T16:50:41Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:08:49Z
user: login: ithiame
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T11:38:16Z
user: login: coyotte508
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T12:34:33Z
user: login: coyotte508
body: :crossed_fingers: 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T15:19:49Z
user: login: coyotte508
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T10:15:36Z

Review comments:
diff_hunk: @@ -26,4 +26,5 @@ export interface PaidSubscription extends Timestamps {
 	}>;
 
 	cancelledAt?: Date;
+	stockByProductId?: Record<string, { available: number; total: number; used: number }>;
path: src/lib/types/PaidSubscription.ts
user: login: coyotte508
body: ```suggestion
	freeProductsById?: Record<string, { available: number; total: number; used: number }>;
```
created_at: 2025-04-17T14:44:39Z
author_association: CONTRIBUTOR
diff_hunk: @@ -7,8 +7,10 @@ export interface Discount extends Timestamps {
 	subscriptionIds: string[];
 	/* If empty, works on all products */
 	productIds: Product['_id'][];
-	percentage: number;
+	percentage?: number;
 	wholeCatalog: boolean;
 	beginsAt: Date;
 	endsAt: Date | null;
+	mode?: 'productPercentage' | 'freeProductQuantity';
+	quantityPerProduct?: Record<string, number>;
path: src/lib/types/Discount.ts
user: login: coyotte508
body: ```suggestion
	freeProductsById?: Record<string, number>;
```
created_at: 2025-04-17T14:46:07Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1608,6 +1615,19 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 				throw new Error('Failed to update subscription');
 			}
 		} else {
+			const stockByProductId: Record<string, { total: number; available: number; used: number }> =
+				{};
+			for (const discount of discounts) {
+				if (discount.quantityPerProduct) {
+					for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+						stockByProductId[productId] = {
+							total: quantity,
+							available: quantity,
+							used: 0
+						};
+					}
+				}
+			}
path: src/lib/server/orders.ts
user: login: coyotte508
body: Just in case, you should handle multiple different discounts giving the same free products for a specific subscripition

eg if discount D1 gives 2 productA and discount D2 gives 1 productA, there should be 3 free productA
created_at: 2025-04-17T14:49:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1617,7 +1646,8 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 					paidUntil: add(new Date(), { [`${runtimeConfig.subscriptionDuration}s`]: 1 }),
 					createdAt: new Date(),
 					updatedAt: new Date(),
-					notifications: []
+					notifications: [],
+					...(freeProductsById && { freeProductsById })
path: src/lib/server/orders.ts
user: login: coyotte508
body: ```suggestion
					...(!isEmpty(freeProductsById) && { freeProductsById })
```

because `freeProductsById` is initialized wiht an empty object
created_at: 2025-04-17T20:14:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -203,7 +203,9 @@ export async function load(params) {
 			.filter((d) => d._id !== null)
 			.map((d) => [
 				d._id,
-				wholeDiscount !== undefined && wholeDiscount > d.discountPercent
+				wholeDiscount !== undefined &&
+				d.discountPercent !== undefined &&
+				wholeDiscount > d.discountPercent
path: src/routes/(app)/+layout.server.ts
user: login: coyotte508
body: this is not correct, if there's a whole discount and no individual discount, it will fallback to `d.discountPercent` which is `undefined` in that case
created_at: 2025-04-17T20:21:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -626,9 +627,11 @@ export async function createOrder(
 			.filter((d) => d._id !== null)
 			.map((d) => [
 				d._id,
-				wholeDiscount !== undefined && wholeDiscount > d.discountPercent
+				wholeDiscount !== undefined &&
+				d.discountPercent !== undefined &&
+				wholeDiscount > d.discountPercent
 					? wholeDiscount
-					: d.discountPercent
+					: d.discountPercent ?? 0
path: src/lib/server/orders.ts
user: login: coyotte508
body: This is still not correct.

Delete all the discounts you have.

Create just a single whole discount of 50%.

Does the product still get the discount or not? (before / after this PR)
created_at: 2025-04-18T12:13:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1583,6 +1584,15 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
+	const discounts = await collections.discounts
+		.find({
+			subscriptionIds: {
+				$in: order.items
+					.filter((item) => item.product.type === 'subscription')
+					.map((sub) => sub.product._id)
+			}
+		})
+		.toArray();
path: src/lib/server/orders.ts
user: login: coyotte508
body: The DB call should only be run if some items are subscriptions
created_at: 2025-04-18T13:39:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -144,7 +145,7 @@ export const load = async ({ params, locals }) => {
 				}
 		  )
 		: null;
-
+	console.log(discount);
path: src/routes/(app)/product/[id]/+page.server.ts
user: login: coyotte508
body: can you remove?
created_at: 2025-04-18T13:40:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -294,7 +294,7 @@
 					<PriceTag
 						currency={data.product.price.currency}
 						amount={(data.product.hasVariations ? customAmount : data.product.price.amount) *
-							(data.discount ? 1 - data.discount.percentage / 100 : 1)}
+							(data.discount ? 1 - (data.discount.percentage ?? 0) / 100 : 1)}
path: src/routes/(app)/product/[id]/+page.svelte
user: login: coyotte508
body: ```suggestion
							(data.discount?.percentage ? 1 - data.discount.percentage / 100 : 1)}
```
created_at: 2025-04-18T13:41:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,32 +30,56 @@ export const load = async () => {
 
 export const actions: Actions = {
 	default: async function ({ request }) {
-		const data = await request.formData();
+		const formData = await request.formData();
+		const quantityPerProductRecord: Record<string, number> = {};
+		for (const [key, value] of formData.entries()) {
+			const match = key.match(/^quantityPerProduct\[(.+?)\]$/);
+			if (match) {
+				quantityPerProductRecord[match[1]] = Number(value);
+			}
+		}
 
-		const { name, percentage, subscriptionIds, productIds, wholeCatalog, beginsAt, endsAt } = z
+		const {
+			name,
+			percentage,
+			subscriptionIds,
+			productIds,
+			wholeCatalog,
+			beginsAt,
+			endsAt,
+			quantityPerProduct,
+			mode
+		} = z
 			.object({
 				name: z.string().min(1).max(MAX_NAME_LIMIT),
 				productIds: z.string().array(),
 				subscriptionIds: z.string().array().min(1),
-				percentage: z.string().regex(/^\d+(\.\d+)?$/),
+				percentage: z
+					.string()
+					.regex(/^\d+(\.\d+)?$/)
+					.optional(),
 				wholeCatalog: z.boolean({ coerce: true }).default(false),
 				beginsAt: z.date({ coerce: true }),
-				endsAt: z.date({ coerce: true }).optional()
+				endsAt: z.date({ coerce: true }).optional(),
+				quantityPerProduct: z.record(z.string(), z.number().min(0).max(100)).optional(),
+				mode: z.enum(['productPercentage', 'freeProductQuantity'])
 			})
 			.parse({
-				name: data.get('name'),
-				subscriptionIds: JSON.parse(String(data.get('subscriptionIds'))).map(
+				name: formData.get('name'),
+				subscriptionIds: JSON.parse(String(formData.get('subscriptionIds'))).map(
 					(x: { value: string }) => x.value
 				),
-				productIds: JSON.parse(String(data.get('productIds') ?? '[]')).map(
+				productIds: JSON.parse(String(formData.get('productIds') ?? '[]')).map(
 					(x: { value: string }) => x.value
 				),
-				wholeCatalog: data.get('wholeCatalog'),
-				percentage: data.get('percentage'),
-				beginsAt: data.get('beginsAt'),
-				endsAt: data.get('endsAt') || undefined
+				wholeCatalog: formData.get('wholeCatalog'),
+				percentage: formData.get('percentage') || '0',
+				beginsAt: formData.get('beginsAt'),
+				endsAt: formData.get('endsAt') || undefined,
+				quantityPerProduct: quantityPerProductRecord,
+				mode: formData.get('mode')
 			});
-
+		console;
path: src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts
user: login: coyotte508
body: remove
created_at: 2025-04-18T13:50:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -64,6 +89,8 @@ export const actions: Actions = {
 			wholeCatalog,
 			percentage: Number(percentage),
path: src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts
user: login: coyotte508
body: This can be NaN

Should either be unset or set to `null`
created_at: 2025-04-18T13:51:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,4 +11,6 @@ export interface Discount extends Timestamps {
 	wholeCatalog: boolean;
 	beginsAt: Date;
 	endsAt: Date | null;
+	mode?: 'productPercentage' | 'freeProductQuantity';
path: src/lib/types/Discount.ts
user: login: coyotte508
body: ```suggestion
	mode?: 'percentage' | 'freeProducts';
```
created_at: 2025-04-18T13:52:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -11,4 +11,6 @@ export interface Discount extends Timestamps {
 	wholeCatalog: boolean;
 	beginsAt: Date;
 	endsAt: Date | null;
+	mode?: 'productPercentage' | 'freeProductQuantity';
+	quantityPerProduct?: Record<string, number>;
 }
path: src/lib/types/Discount.ts
user: login: coyotte508
body: ```suggestion
} & ({
	mode: 'percentage';
	percentage: number;
} | {
	mode: 'freeProducts';
	quantityPerProduct: Record<string, number>;
})
```

and a migration to add `mode` to existing discounts

you can use `z.union` on just the two fields when you need to use zod / clean up typing
created_at: 2025-04-18T13:54:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,7 +139,7 @@ export async function load(params) {
 		? await collections.discounts
 				.aggregate<{
 					_id: Product['_id'] | null;
-					discountPercent: Discount['percentage'];
+					discountPercent: number;
path: src/routes/(app)/+layout.server.ts
user: login: coyotte508
body: Should add `mode: 'percentage'` in the query
created_at: 2025-04-22T12:06:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -46,30 +47,51 @@ export const actions = {
 			throw error(404, 'discount not found');
 		}
 
-		const data = await request.formData();
-
-		const { name, subscriptionIds, productIds, wholeCatalog, percentage, beginsAt, endsAt } = z
+		const formData = await request.formData();
+		const quantityPerProductRecord: Record<string, number> = {};
+		for (const [key, value] of formData.entries()) {
+			const match = key.match(/^quantityPerProduct\[(.+?)\]$/);
+			if (match) {
+				quantityPerProductRecord[match[1]] = Number(value);
+			}
+		}
+		const {
+			name,
+			subscriptionIds,
+			productIds,
+			wholeCatalog,
+			percentage,
+			beginsAt,
+			endsAt,
+			quantityPerProduct
+		} = z
 			.object({
 				name: z.string().min(1).max(MAX_NAME_LIMIT),
-				productIds: z.string().array(),
+				productIds: z.string().array().optional(),
 				subscriptionIds: z.string().array(),
-				percentage: z.string().regex(/^\d+(\.\d+)?$/),
+				percentage: z
+					.string()
+					.regex(/^\d+(\.\d+)?$/)
+					.optional(),
path: src/routes/(app)/admin[[hash=admin_hash]]/discount/[id]/+page.server.ts
user: login: coyotte508
body: It shouldn't be put as optional.


You parse one or the other, depending on `discount.mode`.

But currently on the frontend I can put all those as empty even for "percent" discounts
created_at: 2025-04-22T12:09:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -29,45 +30,90 @@ export const load = async () => {
 
 export const actions: Actions = {
 	default: async function ({ request }) {
-		const data = await request.formData();
+		const formData = await request.formData();
+		const quantityPerProductRecord: Record<string, number> = {};
+		for (const [key, value] of formData.entries()) {
+			const match = key.match(/^quantityPerProduct\[(.+?)\]$/);
+			if (match) {
+				quantityPerProductRecord[match[1]] = Number(value);
+			}
+		}
 
-		const { name, percentage, subscriptionIds, productIds, wholeCatalog, beginsAt, endsAt } = z
+		const {
+			name,
+			percentage,
+			subscriptionIds,
+			productIds,
+			wholeCatalog,
+			beginsAt,
+			endsAt,
+			quantityPerProduct,
+			mode
+		} = z
 			.object({
 				name: z.string().min(1).max(MAX_NAME_LIMIT),
 				productIds: z.string().array(),
 				subscriptionIds: z.string().array().min(1),
-				percentage: z.string().regex(/^\d+(\.\d+)?$/),
+				percentage: z
+					.string()
+					.regex(/^\d+(\.\d+)?$/)
+					.optional(),
path: src/routes/(app)/admin[[hash=admin_hash]]/discount/new/+page.server.ts
user: login: coyotte508
body: Same comment this shouldn't be optional.

You can do a follow up `z.parse` below depending on `mode`
created_at: 2025-04-22T12:11:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -140,7 +139,7 @@ export async function load(params) {
 		? await collections.discounts
 				.aggregate<{
 					_id: Product['_id'] | null;
-					discountPercent: Discount['percentage'];
+					discountPercent: number;
path: src/routes/(app)/+layout.server.ts
user: login: coyotte508
body: In the query below @ithiame not in the type

same for everywhere it's used the same way (create order, on a product's page, ...)
created_at: 2025-04-22T23:15:22Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,16 +113,32 @@ export const load = async ({ params, locals }) => {
 		.find({ productId: params.id })
 		.sort({ createdAt: 1 })
 		.toArray();
-	const subscriptions = await collections.paidSubscriptions
+	const freeProductSubscriptions = await collections.paidSubscriptions
 		.find({
 			...userQuery(userIdentifier(locals)),
-			paidUntil: { $gt: new Date() }
+			paidUntil: { $gt: new Date() },
+			[`freeProductsById.${product._id}`]: { $exists: true }
 		})
+		.sort({ [`freeProductsById.${product._id}.available`]: -1 })
+		.limit(1)
 		.toArray();
+	const subscriptions = freeProductSubscriptions
+		? []
+		: await collections.paidSubscriptions
+				.find({
+					...userQuery(userIdentifier(locals)),
+					paidUntil: { $gt: new Date() }
+				})
+				.toArray();
 	const discount = subscriptions.length
 		? await collections.discounts.findOne(
 				{
-					$or: [{ wholeCatalog: true }, { productIds: product._id }],
+					$or: [
+						{ wholeCatalog: true },
+						{ productIds: product._id },
+						{ mode: 'percentage' },
+						{ mode: 'freeProducts' }
+					],
path: src/routes/(app)/product/[id]/+page.server.ts
user: login: coyotte508
body: ```suggestion
					$or: [
						{ wholeCatalog: true },
						{ productIds: product._id },
					],
					mode: 'percentage',
```
created_at: 2025-04-24T11:34:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -112,16 +113,32 @@ export const load = async ({ params, locals }) => {
 		.find({ productId: params.id })
 		.sort({ createdAt: 1 })
 		.toArray();
-	const subscriptions = await collections.paidSubscriptions
+	const freeProductSubscriptions = await collections.paidSubscriptions
 		.find({
 			...userQuery(userIdentifier(locals)),
-			paidUntil: { $gt: new Date() }
+			paidUntil: { $gt: new Date() },
+			[`freeProductsById.${product._id}`]: { $exists: true }
 		})
+		.sort({ [`freeProductsById.${product._id}.available`]: -1 })
+		.limit(1)
 		.toArray();
path: src/routes/(app)/product/[id]/+page.server.ts
user: login: coyotte508
body: you can remove this and just use a `.find()` from the results of the below db calls.

Eg  `subscriptions.find(s => (s.freeProductsById?.[product._id]?.available ?? 0) > 0)`

You should also sum the available amount from all the free product subscriptions.

Maybe you can just pass the available amount to the frontend instead of the whole subscriptions
created_at: 2025-04-24T11:37:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -113,36 +113,25 @@ export const load = async ({ params, locals }) => {
 		.find({ productId: params.id })
 		.sort({ createdAt: 1 })
 		.toArray();
-	const freeProductSubscriptions = await collections.paidSubscriptions
+	const subscriptions = await collections.paidSubscriptions
 		.find({
 			...userQuery(userIdentifier(locals)),
-			paidUntil: { $gt: new Date() },
-			[`freeProductsById.${product._id}`]: { $exists: true }
+			paidUntil: { $gt: new Date() }
 		})
-		.sort({ [`freeProductsById.${product._id}.available`]: -1 })
-		.limit(1)
 		.toArray();
-	const subscriptions = freeProductSubscriptions
-		? []
-		: await collections.paidSubscriptions
-				.find({
-					...userQuery(userIdentifier(locals)),
-					paidUntil: { $gt: new Date() }
-				})
-				.toArray();
+	const freeProductSubscriptions = subscriptions.filter(
+		(s) => (s.freeProductsById?.[product._id]?.available ?? 0) > 0
+	);
+	const percentageSubscriptions = freeProductSubscriptions ? [] : subscriptions;
path: src/routes/(app)/product/[id]/+page.server.ts
user: login: coyotte508
body: `freeProductSubscriptions` is always defined, you should use  `freeProductSubscriptions.length` or even compute the sum directly here.

By the way is there a reason for this line? In my opinion we still want to show the discounted price (so if they go beyond their free products, they already know the prices they will get)
created_at: 2025-04-24T13:40:35Z
author_association: CONTRIBUTOR
diff_hunk: @@ -118,6 +119,9 @@ export const load = async ({ params, locals }) => {
 			paidUntil: { $gt: new Date() }
 		})
 		.toArray();
+	const freeProductSubscriptions = subscriptions.filter(
+		(s) => (s.freeProductsById?.[product._id]?.available ?? 0) > 0
+	);
path: src/routes/(app)/product/[id]/+page.server.ts
user: login: coyotte508
body: ```suggestion
	const freeProductsAvailable = sum(subscriptions.map(
		(s) => (s.freeProductsById?.[product._id]?.available ?? 0)
	));
```

and remove the same code below
created_at: 2025-04-24T19:34:08Z
author_association: CONTRIBUTOR
diff_hunk: @@ -163,6 +163,19 @@ export async function addToCartInDb(
 	} else if (product.variations?.length) {
 		throw error(400, 'error matching on variations choice');
 	}
+	if (
+		params.freeQuantity &&
+		(await collections.orders.countDocuments(
+			{
+				...userQuery(params.user),
+				'items.product._id': product._id,
+				status: 'pending'
+			},
+			{ limit: 1 }
+		))
+	) {
+		throw error(400, 'You already have a pending order for this free product: ' + product.name);
+	}
path: src/lib/server/cart.ts
user: login: coyotte508
body: I think what @Tirodem referred to is decreasing the available stock (maybe add it to a "pending" object) as soon as the order is created.

Then the `onOrderPaid` would move the stock from "pending" to "used", and on order cancel would move the stock from pending to available.
created_at: 2025-04-29T19:44:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,6 +371,38 @@ export async function onOrderPaymentFailed(
 		},
 		{ returnDocument: 'after', session: opts?.session }
 	);
+	for (const item of order.items) {
+		if (item.freeQuantity) {
+			const freeProductSubscriptions = await collections.paidSubscriptions
+				.find({
+					[`freeProductsById.${item.product._id}`]: { $exists: true },
+					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
+					paidUntil: { $gt: new Date() }
+				})
+				.toArray();
+
+			for (const sub of freeProductSubscriptions) {
+				await collections.paidSubscriptions.updateOne(
+					{ _id: sub._id },
+					{
+						$inc: {
+							...(item.freeQuantity && {
+								[`freeProductsById.${item.product._id}.used`]: -item.quantity
+							}),
+							...(item.freeQuantity && {
+								[`freeProductsById.${item.product._id}.available`]: +item.quantity
+							})
+						},
+						$set: {
+							updatedAt: new Date(),
+							notifications: []
+						},
+						$unset: { cancelledAt: 1 }
+					}
+				);
+			}
+		}
+	}
path: src/lib/server/orders.ts
user: login: coyotte508
body: this should only happen if the order is fully cancelled.

Like in this section: https://github.com/be-BOP-io-SA/be-BOP/pull/1847/files#diff-68e2d47d872b8e27c8f8a5ae4ccdb5409fe481e6c03a49b3c7f323530c1107c0R401-R417

```ts
	if (
		order.status !== ret.value.status &&
		(ret.value.status === 'canceled' ||
			ret.value.status === 'expired' ||
			ret.value.status === 'failed')
	) {
		await collections.scheduleEvents.updateMany(
			{
				orderId: order._id
			},
			{
				$set: {
					status: 'canceled'
				}
			}
		);
	}
```
created_at: 2025-05-02T10:17:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,6 +371,46 @@ export async function onOrderPaymentFailed(
 		},
 		{ returnDocument: 'after', session: opts?.session }
 	);
+	if (
+		ret.value &&
+		order.status !== ret.value.status &&
+		(ret.value.status === 'canceled' ||
+			ret.value.status === 'expired' ||
+			ret.value.status === 'failed')
+	) {
+		for (const item of order.items) {
+			if (item.freeQuantity) {
+				const freeProductSubscriptions = await collections.paidSubscriptions
+					.find({
+						[`freeProductsById.${item.product._id}`]: { $exists: true },
+						[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
+						paidUntil: { $gt: new Date() }
+					})
+					.toArray();
+
+				for (const sub of freeProductSubscriptions) {
+					await collections.paidSubscriptions.updateOne(
+						{ _id: sub._id },
+						{
+							$inc: {
+								...(item.freeQuantity && {
+									[`freeProductsById.${item.product._id}.used`]: -item.quantity
+								}),
+								...(item.freeQuantity && {
+									[`freeProductsById.${item.product._id}.available`]: +item.quantity
+								})
+							},
+							$set: {
+								updatedAt: new Date(),
+								notifications: []
+							},
+							$unset: { cancelledAt: 1 }
+						}
+					);
path: src/lib/server/orders.ts
user: login: coyotte508
body: At the moment if I cancel an order with a paid product, if I have two subscriptoins with that product, both will get an increase of 1. Infinite free item glitch :)
created_at: 2025-05-02T10:44:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,6 +371,34 @@ export async function onOrderPaymentFailed(
 		},
 		{ returnDocument: 'after', session: opts?.session }
 	);
+	if (
+		ret.value &&
+		order.status !== ret.value.status &&
+		(ret.value.status === 'canceled' ||
+			ret.value.status === 'expired' ||
+			ret.value.status === 'failed')
+	) {
+		for (const item of order.items) {
+			if (item.freeProductSources?.length) {
+				for (const source of item.freeProductSources) {
+					await collections.paidSubscriptions.updateOne(
+						{ _id: source.subscriptionId },
+						{
+							$inc: {
+								[`freeProductsById.${item.product._id}.used`]: -source.quantity,
+								[`freeProductsById.${item.product._id}.available`]: source.quantity
+							},
+							$set: {
+								updatedAt: new Date(),
+								notifications: []
+							},
+							$unset: { cancelledAt: 1 }
+						}
+					);
path: src/lib/server/orders.ts
user: login: coyotte508
body: add the `{session}` param
created_at: 2025-05-02T15:40:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -371,6 +371,34 @@ export async function onOrderPaymentFailed(
 		},
 		{ returnDocument: 'after', session: opts?.session }
 	);
+	if (
+		ret.value &&
+		order.status !== ret.value.status &&
+		(ret.value.status === 'canceled' ||
+			ret.value.status === 'expired' ||
+			ret.value.status === 'failed')
+	) {
+		for (const item of order.items) {
+			if (item.freeProductSources?.length) {
+				for (const source of item.freeProductSources) {
+					await collections.paidSubscriptions.updateOne(
+						{ _id: source.subscriptionId },
+						{
+							$inc: {
+								[`freeProductsById.${item.product._id}.used`]: -source.quantity,
+								[`freeProductsById.${item.product._id}.available`]: source.quantity
+							},
+							$set: {
+								updatedAt: new Date(),
+								notifications: []
path: src/lib/server/orders.ts
user: login: coyotte508
body: why update the notifications? It's not related at all
created_at: 2025-05-02T15:41:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -639,6 +670,48 @@ export async function createOrder(
 
 	for (const item of items) {
 		item.discountPercentage ??= discountByProductId.get(item.product._id) ?? wholeDiscount;
+		if (item.freeQuantity) {
+			let quantityToConsume = Math.min(item.quantity, item.freeQuantity);
+			const freeProductSubscriptions = await collections.paidSubscriptions
+				.find({
+					[`freeProductsById.${item.product._id}`]: { $exists: true },
+					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
+					paidUntil: { $gt: new Date() }
+				})
+				.sort({ createdAt: 1 })
+				.toArray();
+			const usedSources: { subscriptionId: string; quantity: number }[] = [];
+
+			for (const sub of freeProductSubscriptions) {
+				if (quantityToConsume <= 0) {
+					break;
+				}
+
+				const subAvailable = sub.freeProductsById?.[item.product._id]?.available ?? 0;
+				if (subAvailable <= 0) {
+					continue;
+				}
+
+				const toUse = Math.min(quantityToConsume, subAvailable);
+				quantityToConsume -= toUse;
+				await collections.paidSubscriptions.updateOne(
+					{ _id: sub._id },
+					{
+						$inc: {
+							[`freeProductsById.${item.product._id}.used`]: toUse,
+							[`freeProductsById.${item.product._id}.available`]: -toUse
+						},
+						$set: {
+							updatedAt: new Date(),
+							notifications: []
path: src/lib/server/orders.ts
user: login: coyotte508
body: do not update the `notificatoins`
created_at: 2025-05-02T15:41:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -639,6 +670,48 @@ export async function createOrder(
 
 	for (const item of items) {
 		item.discountPercentage ??= discountByProductId.get(item.product._id) ?? wholeDiscount;
+		if (item.freeQuantity) {
+			let quantityToConsume = Math.min(item.quantity, item.freeQuantity);
+			const freeProductSubscriptions = await collections.paidSubscriptions
+				.find({
+					[`freeProductsById.${item.product._id}`]: { $exists: true },
+					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
+					paidUntil: { $gt: new Date() }
+				})
+				.sort({ createdAt: 1 })
+				.toArray();
+			const usedSources: { subscriptionId: string; quantity: number }[] = [];
+
+			for (const sub of freeProductSubscriptions) {
+				if (quantityToConsume <= 0) {
+					break;
+				}
+
+				const subAvailable = sub.freeProductsById?.[item.product._id]?.available ?? 0;
+				if (subAvailable <= 0) {
+					continue;
+				}
+
+				const toUse = Math.min(quantityToConsume, subAvailable);
+				quantityToConsume -= toUse;
+				await collections.paidSubscriptions.updateOne(
+					{ _id: sub._id },
+					{
+						$inc: {
+							[`freeProductsById.${item.product._id}.used`]: toUse,
+							[`freeProductsById.${item.product._id}.available`]: -toUse
+						},
+						$set: {
+							updatedAt: new Date(),
+							notifications: []
+						},
+						$unset: { cancelledAt: 1 }
path: src/lib/server/orders.ts
user: login: coyotte508
body: not related? why this update?
created_at: 2025-05-02T15:42:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -639,6 +670,48 @@ export async function createOrder(
 
 	for (const item of items) {
 		item.discountPercentage ??= discountByProductId.get(item.product._id) ?? wholeDiscount;
+		if (item.freeQuantity) {
+			let quantityToConsume = Math.min(item.quantity, item.freeQuantity);
+			const freeProductSubscriptions = await collections.paidSubscriptions
+				.find({
+					[`freeProductsById.${item.product._id}`]: { $exists: true },
+					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
+					paidUntil: { $gt: new Date() }
+				})
+				.sort({ createdAt: 1 })
+				.toArray();
+			const usedSources: { subscriptionId: string; quantity: number }[] = [];
+
+			for (const sub of freeProductSubscriptions) {
+				if (quantityToConsume <= 0) {
+					break;
+				}
+
+				const subAvailable = sub.freeProductsById?.[item.product._id]?.available ?? 0;
+				if (subAvailable <= 0) {
+					continue;
+				}
+
+				const toUse = Math.min(quantityToConsume, subAvailable);
+				quantityToConsume -= toUse;
+				await collections.paidSubscriptions.updateOne(
+					{ _id: sub._id },
+					{
+						$inc: {
+							[`freeProductsById.${item.product._id}.used`]: toUse,
+							[`freeProductsById.${item.product._id}.available`]: -toUse
+						},
+						$set: {
+							updatedAt: new Date(),
+							notifications: []
+						},
+						$unset: { cancelledAt: 1 }
+					}
path: src/lib/server/orders.ts
user: login: coyotte508
body: add a `{session}` param
created_at: 2025-05-02T15:43:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1668,76 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: []
+						},
+						$unset: { cancelledAt: 1 }
+					},
+					{ session }
+				);
+
+				if (!result.modifiedCount) {
+					throw new Error('Failed to update subscription');
+				}
+			} else {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
path: src/lib/server/orders.ts
user: login: coyotte508
body: This whole diff can be removed no? Since we don't care what happens after the order is paid, because the changes happen when the order is created
created_at: 2025-05-02T15:44:53Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1668,76 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: []
+						},
+						$unset: { cancelledAt: 1 }
+					},
+					{ session }
+				);
+
+				if (!result.modifiedCount) {
+					throw new Error('Failed to update subscription');
+				}
+			} else {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
path: src/lib/server/orders.ts
user: login: ithiame
body: No, This code saves the paidSubscription quantity of freeProduct when we pay a subscription product
created_at: 2025-05-02T16:12:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1668,76 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: []
+						},
+						$unset: { cancelledAt: 1 }
+					},
+					{ session }
+				);
+
+				if (!result.modifiedCount) {
+					throw new Error('Failed to update subscription');
+				}
+			} else {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
path: src/lib/server/orders.ts
user: login: coyotte508
body: ok but another problem probably, when you renew the subscription it should add new free products, currently the code doesn't do that (renewing the subcription doesn't add new products, just update teh date)
created_at: 2025-05-02T16:50:41Z
author_association: CONTRIBUTOR
diff_hunk: @@ -639,6 +670,49 @@ export async function createOrder(
 
 	for (const item of items) {
 		item.discountPercentage ??= discountByProductId.get(item.product._id) ?? wholeDiscount;
+		if (item.freeQuantity) {
+			let quantityToConsume = Math.min(item.quantity, item.freeQuantity);
+			const freeProductSubscriptions = await collections.paidSubscriptions
+				.find({
+					[`freeProductsById.${item.product._id}`]: { $exists: true },
+					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
path: src/lib/server/orders.ts
user: login: coyotte508
body: ```suggestion
					[`freeProductsById.${item.product._id}.available`]: { $gt: 0 },
```

you should also restrict the subscriptoin to the user making the order, not all subscriptoins
created_at: 2025-05-05T11:03:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1669,95 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: [],
+							...(Object.keys(freeProductsById).length !== 0 && { freeProductsById })
path: src/lib/server/orders.ts
user: login: coyotte508
body: you can use the `isEmpty` or `isEmptyObject` helper
created_at: 2025-05-05T11:07:16Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1669,95 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: [],
+							...(Object.keys(freeProductsById).length !== 0 && { freeProductsById })
path: src/lib/server/orders.ts
user: login: coyotte508
body: also here you are "resetting" the free products, it should be added to the existing inside the subscriptoin
created_at: 2025-05-05T11:08:28Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1669,95 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: [],
+							...(Object.keys(freeProductsById).length !== 0 && { freeProductsById })
path: src/lib/server/orders.ts
user: login: ithiame
body: It renews the subscription why not resetting free products ?

created_at: 2025-05-05T11:38:15Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1594,44 +1669,95 @@ export async function updateAfterOrderPaid(order: Order, session: ClientSession)
 			productId: { $in: order.items.map((item) => item.product._id) }
 		})
 		.toArray();
-	for (const subscription of order.items.filter((item) => item.product.type === 'subscription')) {
-		const existingSubscription = subscriptions.find(
-			(sub) => sub.productId === subscription.product._id
-		);
+	const subscriptionProducts = order.items.filter((item) => item.product.type === 'subscription');
+	if (subscriptionProducts.length) {
+		const discounts = await collections.discounts
+			.find({
+				subscriptionIds: {
+					$in: order.items
+						.filter((item) => item.product.type === 'subscription')
+						.map((sub) => sub.product._id)
+				}
+			})
+			.toArray();
 
-		if (existingSubscription) {
-			const result = await collections.paidSubscriptions.updateOne(
-				{ _id: existingSubscription._id },
-				{
-					$set: {
-						paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
-							[`${runtimeConfig.subscriptionDuration}s`]: 1
-						}),
-						updatedAt: new Date(),
-						notifications: []
-					},
-					$unset: { cancelledAt: 1 }
-				},
-				{ session }
+		for (const subscription of subscriptionProducts) {
+			const existingSubscription = subscriptions.find(
+				(sub) => sub.productId === subscription.product._id
 			);
 
-			if (!result.modifiedCount) {
-				throw new Error('Failed to update subscription');
+			if (existingSubscription) {
+				const freeProductsById: Record<string, { total: number; available: number; used: number }> =
+					{};
+
+				for (const discount of discounts) {
+					if (discount.mode === 'freeProducts' && discount.quantityPerProduct) {
+						for (const [productId, quantity] of Object.entries(discount.quantityPerProduct)) {
+							if (!freeProductsById[productId]) {
+								freeProductsById[productId] = {
+									total: 0,
+									available: 0,
+									used: 0
+								};
+							}
+							freeProductsById[productId].total += quantity;
+							freeProductsById[productId].available += quantity;
+						}
+					}
+				}
+				const result = await collections.paidSubscriptions.updateOne(
+					{ _id: existingSubscription._id },
+					{
+						$set: {
+							paidUntil: add(max([existingSubscription.paidUntil, new Date()]), {
+								[`${runtimeConfig.subscriptionDuration}s`]: 1
+							}),
+							updatedAt: new Date(),
+							notifications: [],
+							...(Object.keys(freeProductsById).length !== 0 && { freeProductsById })
path: src/lib/server/orders.ts
user: login: coyotte508
body: because you can renew early maybe? If it resets it should be at the renewal date not the early payment date
created_at: 2025-05-05T12:34:33Z
author_association: CONTRIBUTOR

PR comments:
user: login: Tirodem
created_at: 2025-04-25T12:33:29Z
body: Not working for me (detail communicated elsewhere)
Only thing working is the discount amount display on product page.
author_association: CONTRIBUTOR
user: login: coyotte508
created_at: 2025-04-25T17:25:06Z
body: > Not working for me (detail communicated elsewhere) Only thing working is the discount amount display on product page.

Maybe this

> For now it doesn't seem to actually allow the user to get free products, that's fine, but please indicate this in your PR (that this part needs to be done in next PR)

(it's not a full PR, the discount shows on the product page, but it's not propagated to order / cart)
author_association: CONTRIBUTOR
user: login: ithiame
created_at: 2025-04-28T12:31:38Z
body: ![Capture d’écran du 2025-04-28 12-30-30](https://github.com/user-attachments/assets/89a49fb6-35a1-42d0-a2b9-5d0be8075d6a)
![Capture d’écran du 2025-04-28 12-29-51](https://github.com/user-attachments/assets/030cb7b4-92c0-423a-a2df-edf68a005a32)
![Capture d’écran du 2025-04-28 12-27-20](https://github.com/user-attachments/assets/3af1b34c-a0c1-4a04-bf81-3fb007b1aff3)

author_association: CONTRIBUTOR
user: login: Tirodem
created_at: 2025-04-29T09:21:02Z
body: A bit of exploit here ☢️ 

`freeProductsById.id.available` is currently decremented and `freeProductsById.id.used` is currently incremented when an order is paid, seemingly.
It should be :
- decremented when an order is created as pending (or paid in case of full gratuity)
- incremented when the said order expired

As said, if I use all my free products + something non-free on a pending order, I can also use my free products on individual orders, then I can came back to my pending order and finalize it, that way I'll have more free products than expected.

We also have an issue with "quickdraw order" (no more detail here) but it's not related to this PR directly.
author_association: CONTRIBUTOR
user: login: Tirodem
created_at: 2025-05-06T10:05:26Z
body: ```
It should be :

- decremented when an order is created as pending (or paid in case of full gratuity)
- incremented when the said order expired
```
This is not done (or not working) :x:
Decrementation is only made on payment.
author_association: CONTRIBUTOR
user: login: Tirodem
created_at: 2025-05-06T12:58:12Z
body: :x: Standalone free product can be added ad nauseam one by one and still be free.
![image](https://github.com/user-attachments/assets/d92c6dd4-0a05-4427-b76e-9c7ecb21a0ef)
![image](https://github.com/user-attachments/assets/b50d6006-ecfb-468c-aa96-94853b09ae39)


author_association: CONTRIBUTOR
user: login: Tirodem
created_at: 2025-05-08T19:09:57Z
body: Way better !
Sadly there's a bug left.
If I have 1 free product on my standalone product.
I add one, it's free ✅ 
I add another, it's not free ✅ 
I delete the free one from my cart and go to my product page : I have 0 free product left :x:
I delete the second one (not-free) from my cart and go to my product page : I have 1 free product left 🤷 

I expect to have one free product back on my counters when I delete the free product.
author_association: CONTRIBUTOR