const mongoose = require("mongoose"); // Records that a specific user redeemed a specific voucher, and which // (free) order that redemption produced — kept as a subdocument so a // voucher's full redemption history is visible in one place without a // second query. const redemptionSchema = new mongoose.Schema( { user: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, order: { type: mongoose.Schema.Types.ObjectId, ref: "Order", required: true }, redeemedAt: { type: Date, default: Date.now }, }, { _id: false } ); const voucherSchema = new mongoose.Schema( { // Always stored upper-cased so lookups are case-insensitive without // needing a collation — the code itself is not secret in the sense a // password is, but it IS a bearer credential, so it's still generated // with real randomness (see utils/voucherCode.js) rather than // something guessable. code: { type: String, required: true, unique: true, uppercase: true, trim: true, index: true }, course: { type: mongoose.Schema.Types.ObjectId, ref: "Course", required: true, index: true }, createdBy: { type: mongoose.Schema.Types.ObjectId, ref: "User", required: true }, // How many different users may redeem this single code. Defaults to // 1 (one voucher == one seat), but an admin can raise this to hand // the same code to a small group (e.g. a workshop cohort) instead of // generating one per person. maxRedemptions: { type: Number, default: 1, min: 1, max: 10000 }, redemptions: { type: [redemptionSchema], default: [] }, // Optional allow-list of emails (lower-cased) this voucher may be // redeemed by. Empty array = anyone who has the code may redeem it // (up to maxRedemptions). Non-empty = only these accounts may, which // is how an admin "gives a voucher to specific users" rather than // just publishing an open promo code. restrictedToEmails: { type: [String], default: [] }, expiresAt: { type: Date, default: null }, isActive: { type: Boolean, default: true }, // Free-text admin memo, e.g. "Scholarship cohort — Fall 2026". Never // shown to the redeeming user. note: { type: String, default: "", maxlength: 500 }, }, { timestamps: true } ); voucherSchema.methods.isExpired = function isExpired() { return !!(this.expiresAt && this.expiresAt.getTime() < Date.now()); }; voucherSchema.methods.hasRemainingRedemptions = function hasRemainingRedemptions() { return this.redemptions.length < this.maxRedemptions; }; voucherSchema.methods.isRedeemableBy = function isRedeemableBy(email) { if (this.restrictedToEmails.length === 0) return true; return this.restrictedToEmails.includes(String(email).toLowerCase().trim()); }; voucherSchema.methods.hasBeenRedeemedBy = function hasBeenRedeemedBy(userId) { return this.redemptions.some((r) => r.user.toString() === userId.toString()); }; /** Why (if at all) this voucher currently can't be redeemed — null means it's valid to try. */ voucherSchema.methods.redemptionBlockReason = function redemptionBlockReason(user) { if (!this.isActive) return "This voucher has been deactivated."; if (this.isExpired()) return "This voucher has expired."; if (!this.hasRemainingRedemptions()) return "This voucher has already been fully redeemed."; if (!this.isRedeemableBy(user.email)) return "This voucher isn't assigned to your account."; if (this.hasBeenRedeemedBy(user._id)) return "You've already redeemed this voucher."; return null; }; voucherSchema.methods.toAdminJSON = function toAdminJSON() { return { id: this._id, code: this.code, course: this.course, createdBy: this.createdBy, maxRedemptions: this.maxRedemptions, redemptionsCount: this.redemptions.length, redemptions: this.redemptions, restrictedToEmails: this.restrictedToEmails, expiresAt: this.expiresAt, isActive: this.isActive, isExpired: this.isExpired(), note: this.note, createdAt: this.createdAt, }; }; module.exports = mongoose.model("Voucher", voucherSchema);