@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.
Files changed (47) hide show
  1. package/dist/index.d.ts +9 -2
  2. package/dist/index.d.ts.map +1 -1
  3. package/dist/index.js +6 -1
  4. package/dist/routes-bookings-quick-create.d.ts +3 -0
  5. package/dist/routes-bookings-quick-create.d.ts.map +1 -0
  6. package/dist/routes-bookings-quick-create.js +103 -0
  7. package/dist/routes-public.d.ts +22 -22
  8. package/dist/routes.d.ts +279 -18
  9. package/dist/routes.d.ts.map +1 -1
  10. package/dist/routes.js +57 -1
  11. package/dist/schema.d.ts +451 -0
  12. package/dist/schema.d.ts.map +1 -1
  13. package/dist/schema.js +79 -0
  14. package/dist/service-aggregates.d.ts +47 -0
  15. package/dist/service-aggregates.d.ts.map +1 -0
  16. package/dist/service-aggregates.js +106 -0
  17. package/dist/service-bookings-dual-create.d.ts +185 -0
  18. package/dist/service-bookings-dual-create.d.ts.map +1 -0
  19. package/dist/service-bookings-dual-create.js +131 -0
  20. package/dist/service-bookings-quick-create.d.ts +168 -0
  21. package/dist/service-bookings-quick-create.d.ts.map +1 -0
  22. package/dist/service-bookings-quick-create.js +312 -0
  23. package/dist/service-public.d.ts +11 -11
  24. package/dist/service-public.d.ts.map +1 -1
  25. package/dist/service-public.js +79 -36
  26. package/dist/service-vouchers-migration.d.ts +44 -0
  27. package/dist/service-vouchers-migration.d.ts.map +1 -0
  28. package/dist/service-vouchers-migration.js +147 -0
  29. package/dist/service-vouchers.d.ts +157 -0
  30. package/dist/service-vouchers.d.ts.map +1 -0
  31. package/dist/service-vouchers.js +191 -0
  32. package/dist/service.d.ts +180 -17
  33. package/dist/service.d.ts.map +1 -1
  34. package/dist/service.js +4 -0
  35. package/dist/validation-public.d.ts +2 -2
  36. package/dist/validation-public.d.ts.map +1 -1
  37. package/dist/validation-public.js +4 -1
  38. package/dist/validation-shared.d.ts +17 -0
  39. package/dist/validation-shared.d.ts.map +1 -1
  40. package/dist/validation-shared.js +12 -0
  41. package/dist/validation-vouchers.d.ts +62 -0
  42. package/dist/validation-vouchers.d.ts.map +1 -0
  43. package/dist/validation-vouchers.js +46 -0
  44. package/dist/validation.d.ts +1 -0
  45. package/dist/validation.d.ts.map +1 -1
  46. package/dist/validation.js +1 -0
  47. 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
+ };