auth-app-api / src /models /Voucher.js
AndreaNotes1's picture
Upload 46 files
4c6b1e8 verified
Raw History Blame Contribute Delete
4.1 kB
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);