first users only coupons.

This commit is contained in:
shafi54 2026-09-13 00:06:15 +05:30
parent b68b2abe18
commit faea152d5d
13 changed files with 66 additions and 4 deletions

View file

@ -89,6 +89,7 @@ export default function EditCoupon() {
maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined, maxValue: coupon.maxValue ? parseFloat(coupon.maxValue) : undefined,
validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined, validTill: coupon.validTill ? dayjs(coupon.validTill).format('YYYY-MM-DD') : undefined,
maxLimitForUser: coupon.maxLimitForUser || undefined, maxLimitForUser: coupon.maxLimitForUser || undefined,
isFirstOrderOnly: coupon.isFirstOrderOnly,
skuIds: coupon.skuIds, skuIds: coupon.skuIds,
isReservedCoupon: false, // Normal coupons isReservedCoupon: false, // Normal coupons
}; };

View file

@ -42,6 +42,7 @@ const couponValidationSchema = Yup.object().shape({
validTill: Yup.date().optional(), validTill: Yup.date().optional(),
maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(), maxLimitForUser: Yup.number().min(1, 'Must be at least 1').optional(),
exclusiveApply: Yup.boolean().optional(), exclusiveApply: Yup.boolean().optional(),
isFirstOrderOnly: Yup.boolean().optional(),
isUserBased: Yup.boolean(), isUserBased: Yup.boolean(),
isApplyForAll: Yup.boolean(), isApplyForAll: Yup.boolean(),
applicableUsers: Yup.array().of(Yup.number()).optional(), applicableUsers: Yup.array().of(Yup.number()).optional(),
@ -133,6 +134,7 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo
applicableUsers: [], applicableUsers: [],
applicableProducts: [], applicableProducts: [],
exclusiveApply: false, exclusiveApply: false,
isFirstOrderOnly: false,
isReservedCoupon: false, isReservedCoupon: false,
}; };
@ -378,6 +380,20 @@ export default function CouponForm({ onSubmit, isLoading, initialValues }: Coupo
</TouchableOpacity> </TouchableOpacity>
</View> </View>
{/* First Order Only */}
<View style={tw`mb-4`}>
<Text style={tw`text-base mb-2`}>First Order Only</Text>
<TouchableOpacity
onPress={() => setFieldValue('isFirstOrderOnly', !values.isFirstOrderOnly)}
style={tw`flex-row items-center`}
>
<View style={tw`w-5 h-5 border-2 border-gray-300 rounded mr-3 ${values.isFirstOrderOnly ? 'bg-blue-500 border-blue-500' : ''}`}>
{values.isFirstOrderOnly && <Text style={tw`text-white text-center`}></Text>}
</View>
<Text style={tw`text-gray-700`}>Valid only on the customer&apos;s first order</Text>
</TouchableOpacity>
</View>
{/* Target Audience */} {/* Target Audience */}
<Text style={tw`text-base font-bold mb-2 ${isReserved ? 'text-gray-400' : ''}`}> <Text style={tw`text-base font-bold mb-2 ${isReserved ? 'text-gray-400' : ''}`}>
Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''} Target Audience {isReserved ? '(Disabled for Reserved Coupons)' : ''}

View file

@ -37,13 +37,14 @@ const createCouponBodySchema = z.object({
validTill: z.string().optional(), validTill: z.string().optional(),
maxLimitForUser: z.number().optional(), maxLimitForUser: z.number().optional(),
exclusiveApply: z.boolean().optional(), exclusiveApply: z.boolean().optional(),
isFirstOrderOnly: z.boolean().optional(),
}); });
export const couponRouter = router({ export const couponRouter = router({
create: protectedProcedure create: protectedProcedure
.input(createCouponBodySchema) .input(createCouponBodySchema)
.mutation(async ({ input, ctx }): Promise<Coupon[]> => { .mutation(async ({ input, ctx }): Promise<Coupon[]> => {
const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply } = input; const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableUsers, applicableProducts, maxValue, isApplyForAll, validTill, maxLimitForUser, exclusiveApply, isFirstOrderOnly } = input;
// Validation: ensure at least one discount type is provided // Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
@ -108,6 +109,7 @@ export const couponRouter = router({
validTill: validTill ? dayjs(validTill).toDate() : null, validTill: validTill ? dayjs(validTill).toDate() : null,
maxLimitForUser: maxLimitForUser ?? null, maxLimitForUser: maxLimitForUser ?? null,
exclusiveApply: exclusiveApply || false, exclusiveApply: exclusiveApply || false,
isFirstOrderOnly: isFirstOrderOnly || false,
isInvalidated: false, isInvalidated: false,
}, },
applicableUsers, applicableUsers,
@ -192,6 +194,7 @@ export const couponRouter = router({
validTill: updates.validTill !== undefined ? (updates.validTill ? dayjs(updates.validTill).toDate() : null) : existing.validTill, validTill: updates.validTill !== undefined ? (updates.validTill ? dayjs(updates.validTill).toDate() : null) : existing.validTill,
maxLimitForUser: updates.maxLimitForUser ?? existing.maxLimitForUser, maxLimitForUser: updates.maxLimitForUser ?? existing.maxLimitForUser,
exclusiveApply: updates.exclusiveApply ?? existing.exclusiveApply, exclusiveApply: updates.exclusiveApply ?? existing.exclusiveApply,
isFirstOrderOnly: updates.isFirstOrderOnly ?? existing.isFirstOrderOnly,
isInvalidated: updates.isInvalidated ?? existing.isInvalidated, isInvalidated: updates.isInvalidated ?? existing.isInvalidated,
createdBy: existing.createdBy, createdBy: existing.createdBy,
}; };
@ -364,7 +367,7 @@ export const couponRouter = router({
createReservedCoupon: protectedProcedure createReservedCoupon: protectedProcedure
.input(createCouponBodySchema) .input(createCouponBodySchema)
.mutation(async ({ input, ctx }): Promise<any> => { .mutation(async ({ input, ctx }): Promise<any> => {
const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply } = input; const { couponCode, couponCodes, isUserBased, discountPercent, flatDiscount, minOrder, skuIds, applicableProducts, maxValue, validTill, maxLimitForUser, exclusiveApply, isFirstOrderOnly } = input;
// Validation: ensure at least one discount type is provided // Validation: ensure at least one discount type is provided
if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) { if ((!discountPercent && !flatDiscount) || (discountPercent && flatDiscount)) {
@ -408,6 +411,7 @@ export const couponRouter = router({
validTill: validTill ? dayjs(validTill).toDate() : undefined, validTill: validTill ? dayjs(validTill).toDate() : undefined,
maxLimitForUser, maxLimitForUser,
exclusiveApply: exclusiveApply || false, exclusiveApply: exclusiveApply || false,
isFirstOrderOnly: isFirstOrderOnly || false,
createdBy: staffUserId, createdBy: staffUserId,
}, },
applicableProducts applicableProducts

View file

@ -6,6 +6,7 @@ import {
getUserAllCouponsWithRelations as getUserAllCouponsWithRelationsInDb, getUserAllCouponsWithRelations as getUserAllCouponsWithRelationsInDb,
getUserReservedCouponByCode as getUserReservedCouponByCodeInDb, getUserReservedCouponByCode as getUserReservedCouponByCodeInDb,
redeemUserReservedCoupon as redeemUserReservedCouponInDb, redeemUserReservedCoupon as redeemUserReservedCouponInDb,
getUserOrderCount as getUserOrderCountInDb,
} from '@/src/dbService' } from '@/src/dbService'
import type { import type {
UserCouponDisplay, UserCouponDisplay,
@ -72,7 +73,9 @@ export const userCouponRouter = router({
*/ */
// Filter to only coupons applicable to current user // Filter to only coupons applicable to current user
const isFirstOrder = (await getUserOrderCountInDb(userId)) === 0;
const applicableCoupons = allCoupons.filter(coupon => { const applicableCoupons = allCoupons.filter(coupon => {
if (coupon.isFirstOrderOnly && !isFirstOrder) return false;
if(!coupon.isUserBased) return true; if(!coupon.isUserBased) return true;
const applicableUsers = coupon.applicableUsers || []; const applicableUsers = coupon.applicableUsers || [];
return applicableUsers.some(au => au.userId === userId); return applicableUsers.some(au => au.userId === userId);
@ -108,13 +111,15 @@ export const userCouponRouter = router({
}); });
*/ */
// Filter coupons in JS: not invalidated, applicable to user, and not expired // Filter coupons in JS: not invalidated, applicable to user, not expired, and first-order eligible
const isFirstOrder = (await getUserOrderCountInDb(userId)) === 0;
const applicableCoupons = allCoupons.filter(coupon => { const applicableCoupons = allCoupons.filter(coupon => {
const isNotInvalidated = !coupon.isInvalidated; const isNotInvalidated = !coupon.isInvalidated;
const applicableUsers = coupon.applicableUsers || []; const applicableUsers = coupon.applicableUsers || [];
const isApplicable = coupon.isApplyForAll || applicableUsers.some(au => au.userId === userId); const isApplicable = coupon.isApplyForAll || applicableUsers.some(au => au.userId === userId);
const isNotExpired = !coupon.validTill || new Date(coupon.validTill) > new Date(); const isNotExpired = !coupon.validTill || new Date(coupon.validTill) > new Date();
return isNotInvalidated && isApplicable && isNotExpired; const isFirstOrderEligible = !coupon.isFirstOrderOnly || isFirstOrder;
return isNotInvalidated && isApplicable && isNotExpired && isFirstOrderEligible;
}); });
// Categorize coupons // Categorize coupons
@ -186,6 +191,11 @@ export const userCouponRouter = router({
throw new ApiError("You have already redeemed this coupon", 400); throw new ApiError("You have already redeemed this coupon", 400);
} }
const isFirstOrder = (await getUserOrderCountInDb(userId)) === 0;
if (reservedCoupon.isFirstOrderOnly && !isFirstOrder) {
throw new ApiError("This is first order only coupon. You already placed your first order. This coupon doesn't apply to you", 400);
}
const couponResult = await redeemUserReservedCouponInDb(userId, reservedCoupon) const couponResult = await redeemUserReservedCouponInDb(userId, reservedCoupon)
/* /*

View file

@ -0,0 +1,6 @@
-- Migration: Add is_first_order_only flag to coupons and reserved_coupons
-- When true, the coupon can only be applied to a user's first order.
ALTER TABLE `coupons` ADD COLUMN `is_first_order_only` integer DEFAULT false NOT NULL;
ALTER TABLE `reserved_coupons` ADD COLUMN `is_first_order_only` integer DEFAULT false NOT NULL;

View file

@ -22,6 +22,13 @@
"when": 1785336600165, "when": 1785336600165,
"tag": "0002_sku_split", "tag": "0002_sku_split",
"breakpoints": true "breakpoints": true
},
{
"idx": 3,
"version": "6",
"when": 1789236103378,
"tag": "0003_first_order_only",
"breakpoints": true
} }
] ]
} }

View file

@ -90,6 +90,7 @@ export async function createCouponWithRelations(
validTill: input.validTill, validTill: input.validTill,
maxLimitForUser: input.maxLimitForUser, maxLimitForUser: input.maxLimitForUser,
exclusiveApply: input.exclusiveApply, exclusiveApply: input.exclusiveApply,
isFirstOrderOnly: input.isFirstOrderOnly,
}).returning() }).returning()
if (applicableUsers && applicableUsers.length > 0) { if (applicableUsers && applicableUsers.length > 0) {
@ -223,6 +224,7 @@ export async function createReservedCouponWithProducts(
validTill: input.validTill, validTill: input.validTill,
maxLimitForUser: input.maxLimitForUser, maxLimitForUser: input.maxLimitForUser,
exclusiveApply: input.exclusiveApply, exclusiveApply: input.exclusiveApply,
isFirstOrderOnly: input.isFirstOrderOnly ?? false,
createdBy: input.createdBy, createdBy: input.createdBy,
}).returning() }).returning()

View file

@ -451,6 +451,7 @@ export const coupons = sqliteTable('coupons', {
maxLimitForUser: integer('max_limit_for_user'), maxLimitForUser: integer('max_limit_for_user'),
isInvalidated: integer('is_invalidated', { mode: 'boolean' }).notNull().default(false), isInvalidated: integer('is_invalidated', { mode: 'boolean' }).notNull().default(false),
exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false), exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false),
isFirstOrderOnly: integer('is_first_order_only', { mode: 'boolean' }).notNull().default(false),
createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`), createdAt: timestampText('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
}) })
@ -501,6 +502,7 @@ export const reservedCoupons = sqliteTable('reserved_coupons', {
validTill: timestampText('valid_till'), validTill: timestampText('valid_till'),
maxLimitForUser: integer('max_limit_for_user'), maxLimitForUser: integer('max_limit_for_user'),
exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false), exclusiveApply: integer('exclusive_apply', { mode: 'boolean' }).notNull().default(false),
isFirstOrderOnly: integer('is_first_order_only', { mode: 'boolean' }).notNull().default(false),
isRedeemed: integer('is_redeemed', { mode: 'boolean' }).notNull().default(false), isRedeemed: integer('is_redeemed', { mode: 'boolean' }).notNull().default(false),
redeemedBy: integer('redeemed_by').references(() => users.id), redeemedBy: integer('redeemed_by').references(() => users.id),
redeemedAt: timestampText('redeemed_at'), redeemedAt: timestampText('redeemed_at'),

View file

@ -30,6 +30,7 @@ const mapCoupon = (coupon: CouponRow): UserCoupon => ({
maxLimitForUser: coupon.maxLimitForUser ?? null, maxLimitForUser: coupon.maxLimitForUser ?? null,
isInvalidated: coupon.isInvalidated, isInvalidated: coupon.isInvalidated,
exclusiveApply: coupon.exclusiveApply, exclusiveApply: coupon.exclusiveApply,
isFirstOrderOnly: coupon.isFirstOrderOnly,
createdAt: coupon.createdAt, createdAt: coupon.createdAt,
}) })
@ -125,6 +126,7 @@ export async function redeemReservedCoupon(userId: number, reservedCoupon: Reser
validTill: reservedCoupon.validTill, validTill: reservedCoupon.validTill,
maxLimitForUser: reservedCoupon.maxLimitForUser, maxLimitForUser: reservedCoupon.maxLimitForUser,
exclusiveApply: reservedCoupon.exclusiveApply, exclusiveApply: reservedCoupon.exclusiveApply,
isFirstOrderOnly: reservedCoupon.isFirstOrderOnly,
createdBy: reservedCoupon.createdBy, createdBy: reservedCoupon.createdBy,
}).returning() }).returning()

View file

@ -121,6 +121,15 @@ export async function validateAndGetCoupon(
) )
throw new Error('Order amount does not meet coupon minimum requirement') throw new Error('Order amount does not meet coupon minimum requirement')
if (coupon.isFirstOrderOnly) {
const orderCount = await getOrderCount(userId)
if (orderCount > 0) {
throw new Error(
"This is first order only coupon. You already placed your first order. This coupon doesn't apply to you"
)
}
}
return coupon as CouponValidationResult return coupon as CouponValidationResult
} }

View file

@ -19,6 +19,7 @@ export interface Coupon {
validTill: Date | null; validTill: Date | null;
maxLimitForUser: number | null; maxLimitForUser: number | null;
exclusiveApply: boolean; exclusiveApply: boolean;
isFirstOrderOnly: boolean;
isInvalidated: boolean; isInvalidated: boolean;
createdAt: Date; createdAt: Date;
createdBy: number; createdBy: number;

View file

@ -431,6 +431,7 @@ export interface UserCoupon {
maxLimitForUser: number | null; maxLimitForUser: number | null;
isInvalidated: boolean; isInvalidated: boolean;
exclusiveApply: boolean; exclusiveApply: boolean;
isFirstOrderOnly?: boolean;
createdAt: Date; createdAt: Date;
} }

View file

@ -14,4 +14,5 @@ export interface CreateCouponPayload {
applicableUsers?: number[]; applicableUsers?: number[];
applicableProducts?: number[]; applicableProducts?: number[];
exclusiveApply?: boolean; exclusiveApply?: boolean;
isFirstOrderOnly?: boolean;
} }