@bash-app/bash-common 30.427.0 → 30.430.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bashFeedTypes.d.ts +3 -0
- package/dist/bashFeedTypes.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/utils/__tests__/feedPostSocialLinks.test.d.ts +2 -0
- package/dist/utils/__tests__/feedPostSocialLinks.test.d.ts.map +1 -0
- package/dist/utils/__tests__/feedPostSocialLinks.test.js +46 -0
- package/dist/utils/__tests__/feedPostSocialLinks.test.js.map +1 -0
- package/dist/utils/__tests__/paymentUtils.test.js +12 -1
- package/dist/utils/__tests__/paymentUtils.test.js.map +1 -1
- package/dist/utils/__tests__/ticketTierReleaseWindow.test.d.ts +2 -0
- package/dist/utils/__tests__/ticketTierReleaseWindow.test.d.ts.map +1 -0
- package/dist/utils/__tests__/ticketTierReleaseWindow.test.js +90 -0
- package/dist/utils/__tests__/ticketTierReleaseWindow.test.js.map +1 -0
- package/dist/utils/__tests__/ticketTierSchedule.test.js +15 -3
- package/dist/utils/__tests__/ticketTierSchedule.test.js.map +1 -1
- package/dist/utils/feedPostSocialLinks.d.ts +26 -0
- package/dist/utils/feedPostSocialLinks.d.ts.map +1 -0
- package/dist/utils/feedPostSocialLinks.js +159 -0
- package/dist/utils/feedPostSocialLinks.js.map +1 -0
- package/dist/utils/paymentUtils.d.ts +2 -0
- package/dist/utils/paymentUtils.d.ts.map +1 -1
- package/dist/utils/paymentUtils.js +16 -3
- package/dist/utils/paymentUtils.js.map +1 -1
- package/dist/utils/ticketTierSchedule.d.ts.map +1 -1
- package/dist/utils/ticketTierSchedule.js +2 -8
- package/dist/utils/ticketTierSchedule.js.map +1 -1
- package/package.json +1 -1
- package/prisma/schema.prisma +5 -2
- package/src/bashFeedTypes.ts +4 -0
- package/src/index.ts +1 -0
- package/src/utils/__tests__/feedPostSocialLinks.test.ts +62 -0
- package/src/utils/__tests__/paymentUtils.test.ts +13 -0
- package/src/utils/__tests__/ticketTierReleaseWindow.test.ts +117 -0
- package/src/utils/__tests__/ticketTierSchedule.test.ts +15 -3
- package/src/utils/feedPostSocialLinks.ts +174 -0
- package/src/utils/paymentUtils.ts +21 -3
- package/src/utils/ticketTierSchedule.ts +2 -9
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveTicketTierScheduleState,
|
|
3
|
+
scheduleValidationHasErrors,
|
|
4
|
+
validateTicketTierScheduleInput,
|
|
5
|
+
} from "../ticketTierSchedule.js";
|
|
6
|
+
|
|
7
|
+
/** Sale opens at availableAt and closes at expiresAt. Boundaries are inclusive on open, exclusive on the last millisecond before close. */
|
|
8
|
+
const RELEASE = new Date("2026-12-11T01:00:00.000Z");
|
|
9
|
+
const EXPIRES = new Date("2026-12-17T13:00:00.000Z");
|
|
10
|
+
const INVENTORY = { availableInventory: 40 };
|
|
11
|
+
|
|
12
|
+
function shift(instant: Date, ms: number): Date {
|
|
13
|
+
return new Date(instant.getTime() + ms);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("ticket release and expiry windows", () => {
|
|
17
|
+
it("keeps a release-only tier off sale until the release instant, then on sale", () => {
|
|
18
|
+
const tier = { availabilityType: "OnDate", availableAt: RELEASE };
|
|
19
|
+
|
|
20
|
+
const before = resolveTicketTierScheduleState(tier, {
|
|
21
|
+
...INVENTORY,
|
|
22
|
+
now: shift(RELEASE, -1),
|
|
23
|
+
});
|
|
24
|
+
const atRelease = resolveTicketTierScheduleState(tier, {
|
|
25
|
+
...INVENTORY,
|
|
26
|
+
now: RELEASE,
|
|
27
|
+
});
|
|
28
|
+
const later = resolveTicketTierScheduleState(tier, {
|
|
29
|
+
...INVENTORY,
|
|
30
|
+
now: shift(RELEASE, 7 * 24 * 60 * 60 * 1000),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
expect(before.canPurchase).toBe(false);
|
|
34
|
+
expect(before.phase).toBe("TeasePreRelease");
|
|
35
|
+
expect(atRelease.canPurchase).toBe(true);
|
|
36
|
+
expect(atRelease.phase).toBe("Live");
|
|
37
|
+
expect(later.canPurchase).toBe(true);
|
|
38
|
+
expect(later.phase).toBe("Live");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("treats a past release as already on sale", () => {
|
|
42
|
+
const issues = validateTicketTierScheduleInput({
|
|
43
|
+
availabilityType: "OnDate",
|
|
44
|
+
availableAt: shift(RELEASE, -60_000),
|
|
45
|
+
});
|
|
46
|
+
expect(scheduleValidationHasErrors(issues)).toBe(false);
|
|
47
|
+
|
|
48
|
+
const state = resolveTicketTierScheduleState(
|
|
49
|
+
{ availabilityType: "OnDate", availableAt: shift(RELEASE, -60_000) },
|
|
50
|
+
{ ...INVENTORY, now: RELEASE }
|
|
51
|
+
);
|
|
52
|
+
expect(state.canPurchase).toBe(true);
|
|
53
|
+
expect(state.phase).toBe("Live");
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps an expiry-only tier on sale until the expiration instant", () => {
|
|
57
|
+
const tier = { availabilityType: "Immediate", expiresAt: EXPIRES };
|
|
58
|
+
|
|
59
|
+
const before = resolveTicketTierScheduleState(tier, {
|
|
60
|
+
...INVENTORY,
|
|
61
|
+
now: shift(EXPIRES, -1),
|
|
62
|
+
});
|
|
63
|
+
const atExpiry = resolveTicketTierScheduleState(tier, {
|
|
64
|
+
...INVENTORY,
|
|
65
|
+
now: EXPIRES,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
expect(before.canPurchase).toBe(true);
|
|
69
|
+
expect(before.phase).toBe("Live");
|
|
70
|
+
expect(atExpiry.canPurchase).toBe(false);
|
|
71
|
+
expect(atExpiry.phase).toBe("Expired");
|
|
72
|
+
expect(atExpiry.guestVisible).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("sells a dated tier only inside the release-to-expiry window", () => {
|
|
76
|
+
const tier = {
|
|
77
|
+
availabilityType: "OnDate",
|
|
78
|
+
availableAt: RELEASE,
|
|
79
|
+
expiresAt: EXPIRES,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
expect(
|
|
83
|
+
resolveTicketTierScheduleState(tier, {
|
|
84
|
+
...INVENTORY,
|
|
85
|
+
now: shift(RELEASE, -1),
|
|
86
|
+
}).canPurchase
|
|
87
|
+
).toBe(false);
|
|
88
|
+
expect(
|
|
89
|
+
resolveTicketTierScheduleState(tier, {
|
|
90
|
+
...INVENTORY,
|
|
91
|
+
now: RELEASE,
|
|
92
|
+
}).phase
|
|
93
|
+
).toBe("Live");
|
|
94
|
+
expect(
|
|
95
|
+
resolveTicketTierScheduleState(tier, {
|
|
96
|
+
...INVENTORY,
|
|
97
|
+
now: shift(EXPIRES, -1),
|
|
98
|
+
}).canPurchase
|
|
99
|
+
).toBe(true);
|
|
100
|
+
expect(
|
|
101
|
+
resolveTicketTierScheduleState(tier, {
|
|
102
|
+
...INVENTORY,
|
|
103
|
+
now: EXPIRES,
|
|
104
|
+
}).phase
|
|
105
|
+
).toBe("Expired");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("rejects an expiration that is not after the release", () => {
|
|
109
|
+
const issues = validateTicketTierScheduleInput({
|
|
110
|
+
availabilityType: "OnDate",
|
|
111
|
+
availableAt: EXPIRES,
|
|
112
|
+
expiresAt: RELEASE,
|
|
113
|
+
});
|
|
114
|
+
expect(scheduleValidationHasErrors(issues)).toBe(true);
|
|
115
|
+
expect(issues.some((issue) => issue.field === "expiresAt")).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -115,9 +115,21 @@ describe("resolveTicketTierScheduleState", () => {
|
|
|
115
115
|
});
|
|
116
116
|
|
|
117
117
|
describe("validateTicketTierScheduleInput", () => {
|
|
118
|
-
it("
|
|
119
|
-
const
|
|
120
|
-
|
|
118
|
+
it("treats a missing release datetime as already on sale", () => {
|
|
119
|
+
const releaseOnly = validateTicketTierScheduleInput({
|
|
120
|
+
availabilityType: "OnDate",
|
|
121
|
+
availableAt: new Date("2030-06-02T12:00:00Z"),
|
|
122
|
+
});
|
|
123
|
+
const expireOnly = validateTicketTierScheduleInput({
|
|
124
|
+
availabilityType: "Immediate",
|
|
125
|
+
expiresAt: new Date("2030-06-02T12:00:00Z"),
|
|
126
|
+
});
|
|
127
|
+
const unsetRelease = validateTicketTierScheduleInput({
|
|
128
|
+
availabilityType: "OnDate",
|
|
129
|
+
});
|
|
130
|
+
expect(scheduleValidationHasErrors(releaseOnly)).toBe(false);
|
|
131
|
+
expect(scheduleValidationHasErrors(expireOnly)).toBe(false);
|
|
132
|
+
expect(scheduleValidationHasErrors(unsetRelease)).toBe(false);
|
|
121
133
|
});
|
|
122
134
|
|
|
123
135
|
it("rejects expires before release", () => {
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Links stored on a service-mode BashFeed post. Not written back to a profile.
|
|
3
|
+
*/
|
|
4
|
+
export const FEED_POST_SOCIAL_PLATFORMS = [
|
|
5
|
+
"Instagram",
|
|
6
|
+
"LinkedIn",
|
|
7
|
+
"X",
|
|
8
|
+
"Website",
|
|
9
|
+
] as const;
|
|
10
|
+
|
|
11
|
+
export const FEED_POST_SOCIAL_LINK_MAX = 5;
|
|
12
|
+
|
|
13
|
+
export type FeedPostSocialPlatform = (typeof FEED_POST_SOCIAL_PLATFORMS)[number];
|
|
14
|
+
|
|
15
|
+
export type FeedPostSocialLink = {
|
|
16
|
+
platform: FeedPostSocialPlatform;
|
|
17
|
+
url: string;
|
|
18
|
+
label?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
function isPlatform(value: string): value is FeedPostSocialPlatform {
|
|
22
|
+
return (FEED_POST_SOCIAL_PLATFORMS as readonly string[]).includes(value);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** https URL, or null when the value is not a normal web link. */
|
|
26
|
+
export function toFeedPostHttpsUrl(raw: string): string | null {
|
|
27
|
+
const trimmed = raw.trim();
|
|
28
|
+
if (!trimmed) return null;
|
|
29
|
+
if (/^(javascript|data|file|mailto|tel):/i.test(trimmed)) return null;
|
|
30
|
+
|
|
31
|
+
let candidate = trimmed;
|
|
32
|
+
if (/^http:\/\//i.test(candidate)) {
|
|
33
|
+
candidate = `https://${candidate.slice("http://".length)}`;
|
|
34
|
+
} else if (!/^https:\/\//i.test(candidate)) {
|
|
35
|
+
if (candidate.startsWith("@")) return null;
|
|
36
|
+
candidate = `https://${candidate.replace(/^\/+/, "")}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(candidate);
|
|
41
|
+
if (url.protocol !== "https:") return null;
|
|
42
|
+
if (!url.hostname || !url.hostname.includes(".")) return null;
|
|
43
|
+
return url.toString();
|
|
44
|
+
} catch {
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function handleToHttps(platform: FeedPostSocialPlatform, raw: string): string | null {
|
|
50
|
+
const trimmed = raw.trim();
|
|
51
|
+
const handle = trimmed.replace(/^@/, "");
|
|
52
|
+
if (!handle || /\s/.test(handle)) return null;
|
|
53
|
+
if (platform === "Instagram") return toFeedPostHttpsUrl(`https://instagram.com/${handle}`);
|
|
54
|
+
if (platform === "X") return toFeedPostHttpsUrl(`https://x.com/${handle}`);
|
|
55
|
+
if (platform === "LinkedIn") return toFeedPostHttpsUrl(`https://www.linkedin.com/in/${handle}`);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function parseFeedPostSocialLinks(
|
|
60
|
+
input: unknown
|
|
61
|
+
): { links: FeedPostSocialLink[] } | { error: string } {
|
|
62
|
+
if (input == null) return { links: [] };
|
|
63
|
+
if (!Array.isArray(input)) {
|
|
64
|
+
return { error: "Social links must be a list." };
|
|
65
|
+
}
|
|
66
|
+
if (input.length > FEED_POST_SOCIAL_LINK_MAX) {
|
|
67
|
+
return { error: `Add up to ${FEED_POST_SOCIAL_LINK_MAX} links.` };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const links: FeedPostSocialLink[] = [];
|
|
71
|
+
for (const row of input) {
|
|
72
|
+
if (!row || typeof row !== "object") {
|
|
73
|
+
return { error: "Each social link needs a platform and a URL." };
|
|
74
|
+
}
|
|
75
|
+
const record = row as { platform?: unknown; url?: unknown; label?: unknown };
|
|
76
|
+
const platform = typeof record.platform === "string" ? record.platform.trim() : "";
|
|
77
|
+
const urlRaw = typeof record.url === "string" ? record.url : "";
|
|
78
|
+
if (!isPlatform(platform)) {
|
|
79
|
+
return { error: "Use Instagram, LinkedIn, X, or a website." };
|
|
80
|
+
}
|
|
81
|
+
const url =
|
|
82
|
+
toFeedPostHttpsUrl(urlRaw) ??
|
|
83
|
+
(urlRaw.trim().startsWith("@") ? handleToHttps(platform, urlRaw) : null);
|
|
84
|
+
if (!url) {
|
|
85
|
+
return { error: "Links need a normal https address." };
|
|
86
|
+
}
|
|
87
|
+
const label =
|
|
88
|
+
typeof record.label === "string" && record.label.trim()
|
|
89
|
+
? record.label.trim().slice(0, 40)
|
|
90
|
+
: undefined;
|
|
91
|
+
links.push(label ? { platform, url, label } : { platform, url });
|
|
92
|
+
}
|
|
93
|
+
return { links };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function urlKey(url: string): string {
|
|
97
|
+
try {
|
|
98
|
+
const parsed = new URL(url);
|
|
99
|
+
const host = parsed.hostname.replace(/^www\./, "").toLowerCase();
|
|
100
|
+
const path = parsed.pathname.replace(/\/$/, "").toLowerCase();
|
|
101
|
+
return `${host}${path}`;
|
|
102
|
+
} catch {
|
|
103
|
+
return url.trim().toLowerCase();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Drop links that match the poster's own profile URLs. */
|
|
108
|
+
export function withoutPosterSocialLinks(
|
|
109
|
+
links: FeedPostSocialLink[],
|
|
110
|
+
posterUrls: Array<string | null | undefined>
|
|
111
|
+
): FeedPostSocialLink[] {
|
|
112
|
+
const blocked = new Set<string>();
|
|
113
|
+
for (const raw of posterUrls) {
|
|
114
|
+
if (!raw?.trim()) continue;
|
|
115
|
+
const https = toFeedPostHttpsUrl(raw);
|
|
116
|
+
if (https) blocked.add(urlKey(https));
|
|
117
|
+
}
|
|
118
|
+
if (blocked.size === 0) return links;
|
|
119
|
+
return links.filter((link) => !blocked.has(urlKey(link.url)));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function pushUnique(
|
|
123
|
+
links: FeedPostSocialLink[],
|
|
124
|
+
platform: FeedPostSocialPlatform,
|
|
125
|
+
raw: string
|
|
126
|
+
): void {
|
|
127
|
+
if (links.length >= FEED_POST_SOCIAL_LINK_MAX) return;
|
|
128
|
+
const url =
|
|
129
|
+
toFeedPostHttpsUrl(raw) ??
|
|
130
|
+
(raw.trim().startsWith("@") ? handleToHttps(platform, raw) : null);
|
|
131
|
+
if (!url) return;
|
|
132
|
+
if (links.some((link) => link.platform === platform && urlKey(link.url) === urlKey(url))) {
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
links.push({ platform, url });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Prefill from an organization's stored social JSON and website.
|
|
140
|
+
* Accepts `{ instagram, linkedin, x, twitter, website }` or an array of `{ platform, url }`.
|
|
141
|
+
*/
|
|
142
|
+
export function providerLinksFromOrganization(
|
|
143
|
+
socialLinks: unknown,
|
|
144
|
+
websiteUrl: string | null | undefined
|
|
145
|
+
): FeedPostSocialLink[] {
|
|
146
|
+
const links: FeedPostSocialLink[] = [];
|
|
147
|
+
if (websiteUrl?.trim()) {
|
|
148
|
+
pushUnique(links, "Website", websiteUrl);
|
|
149
|
+
}
|
|
150
|
+
if (!socialLinks) return links;
|
|
151
|
+
|
|
152
|
+
if (Array.isArray(socialLinks)) {
|
|
153
|
+
const parsed = parseFeedPostSocialLinks(socialLinks);
|
|
154
|
+
if ("links" in parsed) {
|
|
155
|
+
for (const link of parsed.links) pushUnique(links, link.platform, link.url);
|
|
156
|
+
}
|
|
157
|
+
return links;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (typeof socialLinks !== "object") return links;
|
|
161
|
+
const record = socialLinks as Record<string, unknown>;
|
|
162
|
+
const pairs: Array<[FeedPostSocialPlatform, unknown]> = [
|
|
163
|
+
["Instagram", record.instagram ?? record.Instagram],
|
|
164
|
+
["LinkedIn", record.linkedin ?? record.LinkedIn],
|
|
165
|
+
["X", record.x ?? record.X ?? record.twitter ?? record.Twitter],
|
|
166
|
+
["Website", record.website ?? record.Website ?? record.url],
|
|
167
|
+
];
|
|
168
|
+
for (const [platform, value] of pairs) {
|
|
169
|
+
if (typeof value === "string" && value.trim()) {
|
|
170
|
+
pushUnique(links, platform, value);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
return links;
|
|
174
|
+
}
|
|
@@ -96,7 +96,7 @@ export function calculateTotalPriceWithoutTax(
|
|
|
96
96
|
): number {
|
|
97
97
|
let total = 0;
|
|
98
98
|
ticketTiers.forEach((tier: TicketTier) => {
|
|
99
|
-
//
|
|
99
|
+
// USD and mixed tiers charge the dollar price. Points-only tiers do not.
|
|
100
100
|
if (tier.pricingType !== "BASHPOINTS") {
|
|
101
101
|
const ticketListNumAndDate = ticketList.get(tier.id);
|
|
102
102
|
ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
|
|
@@ -120,8 +120,10 @@ export function calculateTotalBashPoints(
|
|
|
120
120
|
): number {
|
|
121
121
|
let total = 0;
|
|
122
122
|
ticketTiers.forEach((tier: TicketTier) => {
|
|
123
|
-
|
|
124
|
-
|
|
123
|
+
if (
|
|
124
|
+
(tier.pricingType === "BASHPOINTS" || tier.pricingType === "MIXED") &&
|
|
125
|
+
tier.priceBashPoints
|
|
126
|
+
) {
|
|
125
127
|
const ticketListNumAndDate = ticketList.get(tier.id);
|
|
126
128
|
ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
|
|
127
129
|
total += tier.priceBashPoints! * (numAndDate.numberOfTickets ?? 0);
|
|
@@ -131,6 +133,22 @@ export function calculateTotalBashPoints(
|
|
|
131
133
|
return total;
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
/** BashPoints due on tiers that also charge a card (pricingType MIXED). */
|
|
137
|
+
export function calculateMixedPricingBashPoints(
|
|
138
|
+
ticketTiers: TicketTier[],
|
|
139
|
+
ticketList: Map<string, NumberOfTicketsForDate[]>
|
|
140
|
+
): number {
|
|
141
|
+
let total = 0;
|
|
142
|
+
ticketTiers.forEach((tier: TicketTier) => {
|
|
143
|
+
if (tier.pricingType !== "MIXED" || !tier.priceBashPoints) return;
|
|
144
|
+
const ticketListNumAndDate = ticketList.get(tier.id);
|
|
145
|
+
ticketListNumAndDate?.forEach((numAndDate: NumberOfTicketsForDate) => {
|
|
146
|
+
total += tier.priceBashPoints! * (numAndDate.numberOfTickets ?? 0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
return total;
|
|
150
|
+
}
|
|
151
|
+
|
|
134
152
|
export function convertCentsToDollars(
|
|
135
153
|
price: number | undefined | null
|
|
136
154
|
): number {
|
|
@@ -325,19 +325,12 @@ export function validateTicketTierScheduleInput(
|
|
|
325
325
|
bashEvent?: { startDateTime?: Date | string | null }
|
|
326
326
|
): ScheduleValidationIssue[] {
|
|
327
327
|
const issues: ScheduleValidationIssue[] = [];
|
|
328
|
-
const type = tier.availabilityType ?? "Immediate";
|
|
329
328
|
const availableAt = toDate(tier.availableAt);
|
|
330
329
|
const expiresAt = toDate(tier.expiresAt);
|
|
331
330
|
const start = bashEvent ? toDate(bashEvent.startDateTime) : null;
|
|
332
331
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
field: "availableAt",
|
|
336
|
-
message: "Release date and time are required when scheduling a tier.",
|
|
337
|
-
severity: "error",
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
|
|
332
|
+
// No release datetime means the tier is already on sale. Expiration can
|
|
333
|
+
// stand alone, and a release date does not require an expiration.
|
|
341
334
|
if (availableAt && expiresAt && expiresAt <= availableAt) {
|
|
342
335
|
issues.push({
|
|
343
336
|
field: "expiresAt",
|