@voyantjs/finance 0.6.8 → 0.7.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/index.d.ts +9 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -1
- package/dist/routes-bookings-quick-create.d.ts +3 -0
- package/dist/routes-bookings-quick-create.d.ts.map +1 -0
- package/dist/routes-bookings-quick-create.js +103 -0
- package/dist/routes-public.d.ts +22 -22
- package/dist/routes.d.ts +279 -18
- package/dist/routes.d.ts.map +1 -1
- package/dist/routes.js +57 -1
- package/dist/schema.d.ts +451 -0
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +79 -0
- package/dist/service-aggregates.d.ts +47 -0
- package/dist/service-aggregates.d.ts.map +1 -0
- package/dist/service-aggregates.js +106 -0
- package/dist/service-bookings-dual-create.d.ts +185 -0
- package/dist/service-bookings-dual-create.d.ts.map +1 -0
- package/dist/service-bookings-dual-create.js +131 -0
- package/dist/service-bookings-quick-create.d.ts +168 -0
- package/dist/service-bookings-quick-create.d.ts.map +1 -0
- package/dist/service-bookings-quick-create.js +312 -0
- package/dist/service-public.d.ts +11 -11
- package/dist/service-public.d.ts.map +1 -1
- package/dist/service-public.js +79 -36
- package/dist/service-vouchers-migration.d.ts +44 -0
- package/dist/service-vouchers-migration.d.ts.map +1 -0
- package/dist/service-vouchers-migration.js +147 -0
- package/dist/service-vouchers.d.ts +157 -0
- package/dist/service-vouchers.d.ts.map +1 -0
- package/dist/service-vouchers.js +191 -0
- package/dist/service.d.ts +180 -17
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +4 -0
- package/dist/validation-public.d.ts +2 -2
- package/dist/validation-public.d.ts.map +1 -1
- package/dist/validation-public.js +4 -1
- package/dist/validation-shared.d.ts +17 -0
- package/dist/validation-shared.d.ts.map +1 -1
- package/dist/validation-shared.js +12 -0
- package/dist/validation-vouchers.d.ts +62 -0
- package/dist/validation-vouchers.d.ts.map +1 -0
- package/dist/validation-vouchers.js +46 -0
- package/dist/validation.d.ts +1 -0
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +1 -0
- package/package.json +9 -8
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { eq, sql } from "drizzle-orm";
|
|
2
|
+
import { paymentInstruments, vouchers } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* Pulls a (possibly nested, array-wrapped, or null) value out of a JSONB
|
|
5
|
+
* metadata column. Keeps the narrow runtime checks local so callers can stay
|
|
6
|
+
* declarative about the shape they expect.
|
|
7
|
+
*/
|
|
8
|
+
function asRecord(metadata) {
|
|
9
|
+
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata)) {
|
|
10
|
+
return null;
|
|
11
|
+
}
|
|
12
|
+
return metadata;
|
|
13
|
+
}
|
|
14
|
+
function asString(record, key) {
|
|
15
|
+
const value = record?.[key];
|
|
16
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
17
|
+
}
|
|
18
|
+
function asNumber(record, key) {
|
|
19
|
+
const value = record?.[key];
|
|
20
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
21
|
+
}
|
|
22
|
+
function asStringArray(record, key) {
|
|
23
|
+
const value = record?.[key];
|
|
24
|
+
if (!Array.isArray(value))
|
|
25
|
+
return [];
|
|
26
|
+
return value.filter((entry) => typeof entry === "string" && entry.length > 0);
|
|
27
|
+
}
|
|
28
|
+
function asDate(value) {
|
|
29
|
+
if (!value)
|
|
30
|
+
return null;
|
|
31
|
+
const parsed = new Date(value);
|
|
32
|
+
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Backfill the `vouchers` table from legacy voucher rows in
|
|
36
|
+
* `payment_instruments`. A legacy voucher is a row with `instrumentType =
|
|
37
|
+
* 'voucher'` whose code lives in one of `metadata.code`, `external_token`, or
|
|
38
|
+
* `direct_bill_reference`, and whose balance lives in
|
|
39
|
+
* `metadata.remainingAmountCents` (falling back to `metadata.amountCents` when
|
|
40
|
+
* no redemption has touched the row).
|
|
41
|
+
*
|
|
42
|
+
* The migration is idempotent: rows whose code already exists in the new
|
|
43
|
+
* `vouchers` table are skipped so re-running the script after a partial run
|
|
44
|
+
* (or after issuing new vouchers via the first-class API) is safe.
|
|
45
|
+
*
|
|
46
|
+
* Why skip rather than update: the new table treats `code` as a primary lookup
|
|
47
|
+
* key and the legacy path has already been read-only-fallback since #256
|
|
48
|
+
* landed, so any voucher that exists in both tables is by definition already
|
|
49
|
+
* migrated. Picking one source of truth avoids clobbering balances the
|
|
50
|
+
* operator may have adjusted through the new redemption flow.
|
|
51
|
+
*/
|
|
52
|
+
export async function migrateVouchersFromPaymentInstruments(db, options = {}) {
|
|
53
|
+
const dryRun = options.dryRun ?? false;
|
|
54
|
+
const skipped = [];
|
|
55
|
+
let migrated = 0;
|
|
56
|
+
const candidates = await db
|
|
57
|
+
.select()
|
|
58
|
+
.from(paymentInstruments)
|
|
59
|
+
.where(eq(paymentInstruments.instrumentType, "voucher"));
|
|
60
|
+
for (const instrument of candidates) {
|
|
61
|
+
const metadata = asRecord(instrument.metadata);
|
|
62
|
+
const code = asString(metadata, "code") ?? instrument.externalToken ?? instrument.directBillReference;
|
|
63
|
+
if (!code) {
|
|
64
|
+
skipped.push({ paymentInstrumentId: instrument.id, reason: "missing_code" });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
const currency = asString(metadata, "currency");
|
|
68
|
+
if (!currency || currency.length !== 3) {
|
|
69
|
+
skipped.push({ paymentInstrumentId: instrument.id, reason: "missing_currency" });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
const initialAmountCents = asNumber(metadata, "amountCents");
|
|
73
|
+
if (initialAmountCents === null || initialAmountCents <= 0) {
|
|
74
|
+
skipped.push({ paymentInstrumentId: instrument.id, reason: "missing_amount" });
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const remainingAmountCents = asNumber(metadata, "remainingAmountCents") ?? initialAmountCents;
|
|
78
|
+
const [existing] = await db
|
|
79
|
+
.select({ id: vouchers.id })
|
|
80
|
+
.from(vouchers)
|
|
81
|
+
.where(sql `lower(${vouchers.code}) = ${code.toLowerCase()}`)
|
|
82
|
+
.limit(1);
|
|
83
|
+
if (existing) {
|
|
84
|
+
skipped.push({ paymentInstrumentId: instrument.id, reason: "already_migrated" });
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const expiresAt = asDate(asString(metadata, "expiresAt"));
|
|
88
|
+
// OpenTravel uses `effectiveDate`; some legacy payloads also wrote
|
|
89
|
+
// `validFrom` directly. Check both so existing rows aren't silently
|
|
90
|
+
// dropped.
|
|
91
|
+
const validFrom = asDate(asString(metadata, "validFrom")) ?? asDate(asString(metadata, "effectiveDate"));
|
|
92
|
+
const seriesCode = asString(metadata, "seriesCode");
|
|
93
|
+
const bookingIds = asStringArray(metadata, "bookingIds");
|
|
94
|
+
const sourceBookingId = asString(metadata, "bookingId") ?? bookingIds[0] ?? null;
|
|
95
|
+
// Collapse the legacy status/balance pair onto the new enum. If there's no
|
|
96
|
+
// balance left, treat as already spent; otherwise follow the instrument's
|
|
97
|
+
// own active/inactive flag.
|
|
98
|
+
const status = remainingAmountCents <= 0 ? "redeemed" : instrument.status === "active" ? "active" : "void";
|
|
99
|
+
if (dryRun) {
|
|
100
|
+
migrated++;
|
|
101
|
+
options.onRowMigrated?.({ paymentInstrumentId: instrument.id, voucherCode: code });
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
await db.insert(vouchers).values({
|
|
106
|
+
code,
|
|
107
|
+
seriesCode,
|
|
108
|
+
status,
|
|
109
|
+
currency: currency.toUpperCase(),
|
|
110
|
+
initialAmountCents,
|
|
111
|
+
remainingAmountCents: Math.max(0, remainingAmountCents),
|
|
112
|
+
issuedToPersonId: instrument.personId ?? null,
|
|
113
|
+
issuedToOrganizationId: instrument.organizationId ?? null,
|
|
114
|
+
// We don't know the original source (refund vs gift vs promo) from the
|
|
115
|
+
// legacy shape, so mark everything as `manual` — operators can reclassify
|
|
116
|
+
// later via PATCH /vouchers/:id.
|
|
117
|
+
sourceType: "manual",
|
|
118
|
+
sourceBookingId,
|
|
119
|
+
notes: instrument.notes ?? null,
|
|
120
|
+
validFrom,
|
|
121
|
+
expiresAt,
|
|
122
|
+
createdAt: instrument.createdAt,
|
|
123
|
+
updatedAt: instrument.updatedAt,
|
|
124
|
+
});
|
|
125
|
+
migrated++;
|
|
126
|
+
options.onRowMigrated?.({ paymentInstrumentId: instrument.id, voucherCode: code });
|
|
127
|
+
}
|
|
128
|
+
catch (error) {
|
|
129
|
+
// Unique-index collision is the only realistic insert failure here
|
|
130
|
+
// (another concurrent migration or a race with a manual issuance). Record
|
|
131
|
+
// it as a skip rather than aborting the batch so a retry finishes the
|
|
132
|
+
// rest.
|
|
133
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
134
|
+
if (message.includes("uidx_vouchers_code") || message.includes("duplicate key")) {
|
|
135
|
+
skipped.push({ paymentInstrumentId: instrument.id, reason: "duplicate_code_collision" });
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
candidates: candidates.length,
|
|
143
|
+
migrated,
|
|
144
|
+
skipped,
|
|
145
|
+
dryRun,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { PostgresJsDatabase } from "drizzle-orm/postgres-js";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
import type { insertVoucherSchema, redeemVoucherSchema, updateVoucherSchema, voucherListQuerySchema } from "./validation-vouchers.js";
|
|
4
|
+
type CreateVoucherInput = z.infer<typeof insertVoucherSchema>;
|
|
5
|
+
type UpdateVoucherInput = z.infer<typeof updateVoucherSchema>;
|
|
6
|
+
type RedeemVoucherInput = z.infer<typeof redeemVoucherSchema>;
|
|
7
|
+
type VoucherListQuery = z.infer<typeof voucherListQuerySchema>;
|
|
8
|
+
/**
|
|
9
|
+
* Raised by the voucher service. Code + message; route handlers map to HTTP.
|
|
10
|
+
* Reasons the route layer cares about:
|
|
11
|
+
* - `code_in_use` — supplied code collides with an existing voucher
|
|
12
|
+
* - `voucher_not_found` — id-not-found / code-not-found read path
|
|
13
|
+
* - `voucher_inactive` — redeem attempted against non-active status
|
|
14
|
+
* - `voucher_not_started`— validFrom is set and hasn't happened yet
|
|
15
|
+
* - `voucher_expired` — expiresAt has passed
|
|
16
|
+
* - `insufficient_balance` — requested amount > remainingAmountCents
|
|
17
|
+
*/
|
|
18
|
+
export declare class VoucherServiceError extends Error {
|
|
19
|
+
readonly code: "code_in_use" | "voucher_not_found" | "voucher_inactive" | "voucher_not_started" | "voucher_expired" | "insufficient_balance";
|
|
20
|
+
constructor(code: "code_in_use" | "voucher_not_found" | "voucher_inactive" | "voucher_not_started" | "voucher_expired" | "insufficient_balance", message?: string);
|
|
21
|
+
}
|
|
22
|
+
export declare const vouchersService: {
|
|
23
|
+
list(db: PostgresJsDatabase, query: VoucherListQuery): Promise<{
|
|
24
|
+
data: {
|
|
25
|
+
id: string;
|
|
26
|
+
code: string;
|
|
27
|
+
seriesCode: string | null;
|
|
28
|
+
status: "void" | "expired" | "active" | "redeemed";
|
|
29
|
+
currency: string;
|
|
30
|
+
initialAmountCents: number;
|
|
31
|
+
remainingAmountCents: number;
|
|
32
|
+
issuedToPersonId: string | null;
|
|
33
|
+
issuedToOrganizationId: string | null;
|
|
34
|
+
sourceType: "manual" | "refund" | "cancellation_credit" | "gift" | "promo";
|
|
35
|
+
sourceBookingId: string | null;
|
|
36
|
+
sourcePaymentId: string | null;
|
|
37
|
+
validFrom: Date | null;
|
|
38
|
+
expiresAt: Date | null;
|
|
39
|
+
notes: string | null;
|
|
40
|
+
issuedByUserId: string | null;
|
|
41
|
+
createdAt: Date;
|
|
42
|
+
updatedAt: Date;
|
|
43
|
+
}[];
|
|
44
|
+
total: number;
|
|
45
|
+
limit: number;
|
|
46
|
+
offset: number;
|
|
47
|
+
}>;
|
|
48
|
+
getById(db: PostgresJsDatabase, id: string): Promise<{
|
|
49
|
+
redemptions: {
|
|
50
|
+
id: string;
|
|
51
|
+
voucherId: string;
|
|
52
|
+
bookingId: string;
|
|
53
|
+
paymentId: string | null;
|
|
54
|
+
amountCents: number;
|
|
55
|
+
createdAt: Date;
|
|
56
|
+
createdByUserId: string | null;
|
|
57
|
+
}[];
|
|
58
|
+
id: string;
|
|
59
|
+
code: string;
|
|
60
|
+
seriesCode: string | null;
|
|
61
|
+
status: "void" | "expired" | "active" | "redeemed";
|
|
62
|
+
currency: string;
|
|
63
|
+
initialAmountCents: number;
|
|
64
|
+
remainingAmountCents: number;
|
|
65
|
+
issuedToPersonId: string | null;
|
|
66
|
+
issuedToOrganizationId: string | null;
|
|
67
|
+
sourceType: "manual" | "refund" | "cancellation_credit" | "gift" | "promo";
|
|
68
|
+
sourceBookingId: string | null;
|
|
69
|
+
sourcePaymentId: string | null;
|
|
70
|
+
validFrom: Date | null;
|
|
71
|
+
expiresAt: Date | null;
|
|
72
|
+
notes: string | null;
|
|
73
|
+
issuedByUserId: string | null;
|
|
74
|
+
createdAt: Date;
|
|
75
|
+
updatedAt: Date;
|
|
76
|
+
} | null>;
|
|
77
|
+
create(db: PostgresJsDatabase, input: CreateVoucherInput, issuedByUserId?: string): Promise<{
|
|
78
|
+
id: string;
|
|
79
|
+
createdAt: Date;
|
|
80
|
+
updatedAt: Date;
|
|
81
|
+
expiresAt: Date | null;
|
|
82
|
+
status: "void" | "expired" | "active" | "redeemed";
|
|
83
|
+
currency: string;
|
|
84
|
+
notes: string | null;
|
|
85
|
+
code: string;
|
|
86
|
+
sourceType: "manual" | "refund" | "cancellation_credit" | "gift" | "promo";
|
|
87
|
+
seriesCode: string | null;
|
|
88
|
+
initialAmountCents: number;
|
|
89
|
+
remainingAmountCents: number;
|
|
90
|
+
issuedToPersonId: string | null;
|
|
91
|
+
issuedToOrganizationId: string | null;
|
|
92
|
+
sourceBookingId: string | null;
|
|
93
|
+
sourcePaymentId: string | null;
|
|
94
|
+
validFrom: Date | null;
|
|
95
|
+
issuedByUserId: string | null;
|
|
96
|
+
} | null>;
|
|
97
|
+
update(db: PostgresJsDatabase, id: string, input: UpdateVoucherInput): Promise<{
|
|
98
|
+
id: string;
|
|
99
|
+
code: string;
|
|
100
|
+
seriesCode: string | null;
|
|
101
|
+
status: "void" | "expired" | "active" | "redeemed";
|
|
102
|
+
currency: string;
|
|
103
|
+
initialAmountCents: number;
|
|
104
|
+
remainingAmountCents: number;
|
|
105
|
+
issuedToPersonId: string | null;
|
|
106
|
+
issuedToOrganizationId: string | null;
|
|
107
|
+
sourceType: "manual" | "refund" | "cancellation_credit" | "gift" | "promo";
|
|
108
|
+
sourceBookingId: string | null;
|
|
109
|
+
sourcePaymentId: string | null;
|
|
110
|
+
validFrom: Date | null;
|
|
111
|
+
expiresAt: Date | null;
|
|
112
|
+
notes: string | null;
|
|
113
|
+
issuedByUserId: string | null;
|
|
114
|
+
createdAt: Date;
|
|
115
|
+
updatedAt: Date;
|
|
116
|
+
} | null>;
|
|
117
|
+
/**
|
|
118
|
+
* Apply a voucher against a booking. Runs in a transaction so
|
|
119
|
+
* `remainingAmountCents` and the redemption row either both land or neither.
|
|
120
|
+
* Guards: voucher must exist, be active, not expired, and have enough
|
|
121
|
+
* balance for the requested amount. When remaining hits zero the voucher
|
|
122
|
+
* flips to `status = 'redeemed'`.
|
|
123
|
+
*/
|
|
124
|
+
redeem(db: PostgresJsDatabase, voucherId: string, input: RedeemVoucherInput, userId?: string): Promise<{
|
|
125
|
+
voucher: {
|
|
126
|
+
id: string;
|
|
127
|
+
code: string;
|
|
128
|
+
seriesCode: string | null;
|
|
129
|
+
status: "void" | "expired" | "active" | "redeemed";
|
|
130
|
+
currency: string;
|
|
131
|
+
initialAmountCents: number;
|
|
132
|
+
remainingAmountCents: number;
|
|
133
|
+
issuedToPersonId: string | null;
|
|
134
|
+
issuedToOrganizationId: string | null;
|
|
135
|
+
sourceType: "manual" | "refund" | "cancellation_credit" | "gift" | "promo";
|
|
136
|
+
sourceBookingId: string | null;
|
|
137
|
+
sourcePaymentId: string | null;
|
|
138
|
+
validFrom: Date | null;
|
|
139
|
+
expiresAt: Date | null;
|
|
140
|
+
notes: string | null;
|
|
141
|
+
issuedByUserId: string | null;
|
|
142
|
+
createdAt: Date;
|
|
143
|
+
updatedAt: Date;
|
|
144
|
+
};
|
|
145
|
+
redemption: {
|
|
146
|
+
id: string;
|
|
147
|
+
createdAt: Date;
|
|
148
|
+
bookingId: string;
|
|
149
|
+
voucherId: string;
|
|
150
|
+
paymentId: string | null;
|
|
151
|
+
amountCents: number;
|
|
152
|
+
createdByUserId: string | null;
|
|
153
|
+
} | null;
|
|
154
|
+
}>;
|
|
155
|
+
};
|
|
156
|
+
export {};
|
|
157
|
+
//# sourceMappingURL=service-vouchers.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service-vouchers.d.ts","sourceRoot":"","sources":["../src/service-vouchers.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAA;AACjE,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAA;AAG5B,OAAO,KAAK,EACV,mBAAmB,EACnB,mBAAmB,EACnB,mBAAmB,EACnB,sBAAsB,EACvB,MAAM,0BAA0B,CAAA;AAEjC,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAC7D,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAC7D,KAAK,kBAAkB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,mBAAmB,CAAC,CAAA;AAC7D,KAAK,gBAAgB,GAAG,CAAC,CAAC,KAAK,CAAC,OAAO,sBAAsB,CAAC,CAAA;AAE9D;;;;;;;;;GASG;AACH,qBAAa,mBAAoB,SAAQ,KAAK;IAE1C,QAAQ,CAAC,IAAI,EACT,aAAa,GACb,mBAAmB,GACnB,kBAAkB,GAClB,qBAAqB,GACrB,iBAAiB,GACjB,sBAAsB;gBANjB,IAAI,EACT,aAAa,GACb,mBAAmB,GACnB,kBAAkB,GAClB,qBAAqB,GACrB,iBAAiB,GACjB,sBAAsB,EAC1B,OAAO,CAAC,EAAE,MAAM;CAKnB;AAsBD,eAAO,MAAM,eAAe;aACX,kBAAkB,SAAS,gBAAgB;;;;;;;;;;;;;;;;;;;;;;;;;gBAuCxC,kBAAkB,MAAM,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;eAW/B,kBAAkB,SAAS,kBAAkB,mBAAmB,MAAM;;;;;;;;;;;;;;;;;;;;eAiCtE,kBAAkB,MAAM,MAAM,SAAS,kBAAkB;;;;;;;;;;;;;;;;;;;;IA0B1E;;;;;;OAMG;eAEG,kBAAkB,aACX,MAAM,SACV,kBAAkB,WAChB,MAAM;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CA0ClB,CAAA"}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { and, asc, desc, eq, gt, ilike, or, sql } from "drizzle-orm";
|
|
2
|
+
import { voucherRedemptions, vouchers } from "./schema.js";
|
|
3
|
+
/**
|
|
4
|
+
* Raised by the voucher service. Code + message; route handlers map to HTTP.
|
|
5
|
+
* Reasons the route layer cares about:
|
|
6
|
+
* - `code_in_use` — supplied code collides with an existing voucher
|
|
7
|
+
* - `voucher_not_found` — id-not-found / code-not-found read path
|
|
8
|
+
* - `voucher_inactive` — redeem attempted against non-active status
|
|
9
|
+
* - `voucher_not_started`— validFrom is set and hasn't happened yet
|
|
10
|
+
* - `voucher_expired` — expiresAt has passed
|
|
11
|
+
* - `insufficient_balance` — requested amount > remainingAmountCents
|
|
12
|
+
*/
|
|
13
|
+
export class VoucherServiceError extends Error {
|
|
14
|
+
code;
|
|
15
|
+
constructor(code, message) {
|
|
16
|
+
super(message ?? code);
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = "VoucherServiceError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
|
|
22
|
+
/**
|
|
23
|
+
* Generate a short, human-friendly voucher code. Crockford-style alphabet
|
|
24
|
+
* (no 0/O/1/I) so codes stay readable when typed from a receipt or email.
|
|
25
|
+
* 12 chars from a 32-symbol alphabet ≈ 60 bits of entropy; unique-index on
|
|
26
|
+
* `code` catches the astronomically-unlikely collision.
|
|
27
|
+
*/
|
|
28
|
+
function generateVoucherCode() {
|
|
29
|
+
const bytes = new Uint8Array(12);
|
|
30
|
+
crypto.getRandomValues(bytes);
|
|
31
|
+
let out = "";
|
|
32
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
33
|
+
const index = (bytes[i] ?? 0) % CODE_ALPHABET.length;
|
|
34
|
+
out += CODE_ALPHABET[index];
|
|
35
|
+
if (i === 3 || i === 7)
|
|
36
|
+
out += "-";
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
export const vouchersService = {
|
|
41
|
+
async list(db, query) {
|
|
42
|
+
const conditions = [];
|
|
43
|
+
if (query.status)
|
|
44
|
+
conditions.push(eq(vouchers.status, query.status));
|
|
45
|
+
if (query.seriesCode)
|
|
46
|
+
conditions.push(eq(vouchers.seriesCode, query.seriesCode));
|
|
47
|
+
if (query.issuedToPersonId) {
|
|
48
|
+
conditions.push(eq(vouchers.issuedToPersonId, query.issuedToPersonId));
|
|
49
|
+
}
|
|
50
|
+
if (query.issuedToOrganizationId) {
|
|
51
|
+
conditions.push(eq(vouchers.issuedToOrganizationId, query.issuedToOrganizationId));
|
|
52
|
+
}
|
|
53
|
+
if (query.hasBalance) {
|
|
54
|
+
conditions.push(gt(vouchers.remainingAmountCents, 0));
|
|
55
|
+
}
|
|
56
|
+
if (query.search) {
|
|
57
|
+
const term = `%${query.search}%`;
|
|
58
|
+
conditions.push(or(ilike(vouchers.code, term), ilike(vouchers.notes, term)));
|
|
59
|
+
}
|
|
60
|
+
const where = conditions.length ? and(...conditions) : undefined;
|
|
61
|
+
const [rows, countResult] = await Promise.all([
|
|
62
|
+
db
|
|
63
|
+
.select()
|
|
64
|
+
.from(vouchers)
|
|
65
|
+
.where(where)
|
|
66
|
+
.limit(query.limit)
|
|
67
|
+
.offset(query.offset)
|
|
68
|
+
.orderBy(desc(vouchers.createdAt)),
|
|
69
|
+
db.select({ count: sql `count(*)::int` }).from(vouchers).where(where),
|
|
70
|
+
]);
|
|
71
|
+
return {
|
|
72
|
+
data: rows,
|
|
73
|
+
total: countResult[0]?.count ?? 0,
|
|
74
|
+
limit: query.limit,
|
|
75
|
+
offset: query.offset,
|
|
76
|
+
};
|
|
77
|
+
},
|
|
78
|
+
async getById(db, id) {
|
|
79
|
+
const [row] = await db.select().from(vouchers).where(eq(vouchers.id, id)).limit(1);
|
|
80
|
+
if (!row)
|
|
81
|
+
return null;
|
|
82
|
+
const redemptions = await db
|
|
83
|
+
.select()
|
|
84
|
+
.from(voucherRedemptions)
|
|
85
|
+
.where(eq(voucherRedemptions.voucherId, id))
|
|
86
|
+
.orderBy(asc(voucherRedemptions.createdAt));
|
|
87
|
+
return { ...row, redemptions };
|
|
88
|
+
},
|
|
89
|
+
async create(db, input, issuedByUserId) {
|
|
90
|
+
const code = input.code?.trim() || generateVoucherCode();
|
|
91
|
+
const [existing] = await db
|
|
92
|
+
.select({ id: vouchers.id })
|
|
93
|
+
.from(vouchers)
|
|
94
|
+
.where(eq(vouchers.code, code))
|
|
95
|
+
.limit(1);
|
|
96
|
+
if (existing) {
|
|
97
|
+
throw new VoucherServiceError("code_in_use");
|
|
98
|
+
}
|
|
99
|
+
const [row] = await db
|
|
100
|
+
.insert(vouchers)
|
|
101
|
+
.values({
|
|
102
|
+
code,
|
|
103
|
+
seriesCode: input.seriesCode ?? null,
|
|
104
|
+
currency: input.currency,
|
|
105
|
+
initialAmountCents: input.amountCents,
|
|
106
|
+
remainingAmountCents: input.amountCents,
|
|
107
|
+
issuedToPersonId: input.issuedToPersonId ?? null,
|
|
108
|
+
issuedToOrganizationId: input.issuedToOrganizationId ?? null,
|
|
109
|
+
sourceType: input.sourceType,
|
|
110
|
+
sourceBookingId: input.sourceBookingId ?? null,
|
|
111
|
+
sourcePaymentId: input.sourcePaymentId ?? null,
|
|
112
|
+
validFrom: input.validFrom ? new Date(input.validFrom) : null,
|
|
113
|
+
expiresAt: input.expiresAt ? new Date(input.expiresAt) : null,
|
|
114
|
+
notes: input.notes ?? null,
|
|
115
|
+
issuedByUserId: issuedByUserId ?? null,
|
|
116
|
+
})
|
|
117
|
+
.returning();
|
|
118
|
+
return row ?? null;
|
|
119
|
+
},
|
|
120
|
+
async update(db, id, input) {
|
|
121
|
+
const [row] = await db
|
|
122
|
+
.update(vouchers)
|
|
123
|
+
.set({
|
|
124
|
+
...(input.status !== undefined ? { status: input.status } : {}),
|
|
125
|
+
...(input.seriesCode !== undefined ? { seriesCode: input.seriesCode } : {}),
|
|
126
|
+
...(input.validFrom !== undefined
|
|
127
|
+
? { validFrom: input.validFrom ? new Date(input.validFrom) : null }
|
|
128
|
+
: {}),
|
|
129
|
+
...(input.expiresAt !== undefined
|
|
130
|
+
? { expiresAt: input.expiresAt ? new Date(input.expiresAt) : null }
|
|
131
|
+
: {}),
|
|
132
|
+
...(input.notes !== undefined ? { notes: input.notes } : {}),
|
|
133
|
+
...(input.issuedToPersonId !== undefined
|
|
134
|
+
? { issuedToPersonId: input.issuedToPersonId }
|
|
135
|
+
: {}),
|
|
136
|
+
...(input.issuedToOrganizationId !== undefined
|
|
137
|
+
? { issuedToOrganizationId: input.issuedToOrganizationId }
|
|
138
|
+
: {}),
|
|
139
|
+
updatedAt: new Date(),
|
|
140
|
+
})
|
|
141
|
+
.where(eq(vouchers.id, id))
|
|
142
|
+
.returning();
|
|
143
|
+
return row ?? null;
|
|
144
|
+
},
|
|
145
|
+
/**
|
|
146
|
+
* Apply a voucher against a booking. Runs in a transaction so
|
|
147
|
+
* `remainingAmountCents` and the redemption row either both land or neither.
|
|
148
|
+
* Guards: voucher must exist, be active, not expired, and have enough
|
|
149
|
+
* balance for the requested amount. When remaining hits zero the voucher
|
|
150
|
+
* flips to `status = 'redeemed'`.
|
|
151
|
+
*/
|
|
152
|
+
async redeem(db, voucherId, input, userId) {
|
|
153
|
+
return db.transaction(async (tx) => {
|
|
154
|
+
const [voucher] = await tx.select().from(vouchers).where(eq(vouchers.id, voucherId)).limit(1);
|
|
155
|
+
if (!voucher)
|
|
156
|
+
throw new VoucherServiceError("voucher_not_found");
|
|
157
|
+
if (voucher.status !== "active")
|
|
158
|
+
throw new VoucherServiceError("voucher_inactive");
|
|
159
|
+
if (voucher.validFrom && voucher.validFrom.getTime() > Date.now()) {
|
|
160
|
+
throw new VoucherServiceError("voucher_not_started");
|
|
161
|
+
}
|
|
162
|
+
if (voucher.expiresAt && voucher.expiresAt.getTime() < Date.now()) {
|
|
163
|
+
throw new VoucherServiceError("voucher_expired");
|
|
164
|
+
}
|
|
165
|
+
if (input.amountCents > voucher.remainingAmountCents) {
|
|
166
|
+
throw new VoucherServiceError("insufficient_balance");
|
|
167
|
+
}
|
|
168
|
+
const [redemption] = await tx
|
|
169
|
+
.insert(voucherRedemptions)
|
|
170
|
+
.values({
|
|
171
|
+
voucherId: voucher.id,
|
|
172
|
+
bookingId: input.bookingId,
|
|
173
|
+
paymentId: input.paymentId ?? null,
|
|
174
|
+
amountCents: input.amountCents,
|
|
175
|
+
createdByUserId: userId ?? null,
|
|
176
|
+
})
|
|
177
|
+
.returning();
|
|
178
|
+
const newRemaining = voucher.remainingAmountCents - input.amountCents;
|
|
179
|
+
const [updated] = await tx
|
|
180
|
+
.update(vouchers)
|
|
181
|
+
.set({
|
|
182
|
+
remainingAmountCents: newRemaining,
|
|
183
|
+
status: newRemaining === 0 ? "redeemed" : voucher.status,
|
|
184
|
+
updatedAt: new Date(),
|
|
185
|
+
})
|
|
186
|
+
.where(eq(vouchers.id, voucher.id))
|
|
187
|
+
.returning();
|
|
188
|
+
return { voucher: updated ?? voucher, redemption: redemption ?? null };
|
|
189
|
+
});
|
|
190
|
+
},
|
|
191
|
+
};
|