File size: 4,095 Bytes
4c6b1e8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
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);