@voyant-travel/finance 0.167.0 → 0.168.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 (196) hide show
  1. package/README.md +23 -9
  2. package/dist/action-ledger-drift.d.ts.map +1 -0
  3. package/dist/booking-lifecycle.d.ts.map +1 -0
  4. package/dist/booking-schedule/subscriber-runtime.d.ts.map +1 -0
  5. package/dist/booking-tax.d.ts +31 -19
  6. package/dist/booking-tax.d.ts.map +1 -0
  7. package/dist/booking-tax.js +48 -15
  8. package/dist/card-payment.d.ts.map +1 -0
  9. package/dist/checkout-routes.d.ts +294 -294
  10. package/dist/checkout-routes.d.ts.map +1 -0
  11. package/dist/checkout-service-plan.d.ts +2 -2
  12. package/dist/checkout-service-plan.d.ts.map +1 -0
  13. package/dist/checkout-service.d.ts.map +1 -0
  14. package/dist/checkout-validation.d.ts.map +1 -0
  15. package/dist/document-download.d.ts.map +1 -0
  16. package/dist/fx-money.d.ts.map +1 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -0
  19. package/dist/index.js +3 -3
  20. package/dist/invoice-fx.d.ts.map +1 -0
  21. package/dist/invoice-number-errors.d.ts.map +1 -0
  22. package/dist/linkables.d.ts.map +1 -0
  23. package/dist/markets-ref.d.ts.map +1 -0
  24. package/dist/mcp-runtime.d.ts.map +1 -0
  25. package/dist/order-payment-sessions.d.ts.map +1 -0
  26. package/dist/payment-adapter-events.d.ts +3 -3
  27. package/dist/payment-link.d.ts.map +1 -0
  28. package/dist/payment-policy-cascade.d.ts.map +1 -0
  29. package/dist/payment-policy.d.ts.map +1 -0
  30. package/dist/payment-schedule/routes.d.ts +2 -2
  31. package/dist/payment-schedule/routes.d.ts.map +1 -0
  32. package/dist/route-runtime.d.ts.map +1 -0
  33. package/dist/routes-action-ledger.d.ts +25 -25
  34. package/dist/routes-action-ledger.d.ts.map +1 -0
  35. package/dist/routes-booking-billing.d.ts +95 -95
  36. package/dist/routes-booking-billing.d.ts.map +1 -0
  37. package/dist/routes-booking-create.d.ts.map +1 -0
  38. package/dist/routes-booking-reads.d.ts +13 -13
  39. package/dist/routes-booking-reads.d.ts.map +1 -0
  40. package/dist/routes-documents.d.ts +11 -11
  41. package/dist/routes-documents.d.ts.map +1 -0
  42. package/dist/routes-invoice-core.d.ts +87 -87
  43. package/dist/routes-invoice-core.d.ts.map +1 -0
  44. package/dist/routes-invoice-documents.d.ts +16 -16
  45. package/dist/routes-invoice-documents.d.ts.map +1 -0
  46. package/dist/routes-invoice-issue.d.ts.map +1 -0
  47. package/dist/routes-invoice-schemas.d.ts +20 -20
  48. package/dist/routes-invoice-schemas.d.ts.map +1 -0
  49. package/dist/routes-payment-processing.d.ts +152 -152
  50. package/dist/routes-payment-processing.d.ts.map +1 -0
  51. package/dist/routes-payment-schemas.d.ts +17 -17
  52. package/dist/routes-payment-schemas.d.ts.map +1 -0
  53. package/dist/routes-payments.d.ts +64 -64
  54. package/dist/routes-payments.d.ts.map +1 -0
  55. package/dist/routes-public-accountant.d.ts +19 -19
  56. package/dist/routes-public.d.ts +102 -102
  57. package/dist/routes-public.d.ts.map +1 -0
  58. package/dist/routes-reference-data.d.ts +33 -33
  59. package/dist/routes-reference-data.d.ts.map +1 -0
  60. package/dist/routes-reports.d.ts +11 -11
  61. package/dist/routes-reports.d.ts.map +1 -0
  62. package/dist/routes-runtime.d.ts +1 -1
  63. package/dist/routes-runtime.d.ts.map +1 -0
  64. package/dist/routes-settlement.d.ts +12 -12
  65. package/dist/routes-settlement.d.ts.map +1 -0
  66. package/dist/routes-shared.d.ts.map +1 -0
  67. package/dist/routes-supplier-invoices.d.ts +75 -75
  68. package/dist/routes-supplier-invoices.d.ts.map +1 -0
  69. package/dist/routes-travel-credits.d.ts +4 -4
  70. package/dist/routes-vouchers.d.ts +401 -0
  71. package/dist/routes-vouchers.d.ts.map +1 -0
  72. package/dist/routes-vouchers.js +176 -0
  73. package/dist/routes.d.ts +477 -477
  74. package/dist/routes.d.ts.map +1 -0
  75. package/dist/runtime-contributor.d.ts.map +1 -0
  76. package/dist/runtime-port.d.ts +20 -24
  77. package/dist/runtime-port.d.ts.map +1 -0
  78. package/dist/runtime-port.js +10 -11
  79. package/dist/runtime.d.ts.map +1 -0
  80. package/dist/schema/booking-billing.d.ts +5 -5
  81. package/dist/schema/booking-billing.d.ts.map +1 -0
  82. package/dist/schema/enums.d.ts.map +1 -0
  83. package/dist/schema/invoice-documents.d.ts +1 -1
  84. package/dist/schema/invoice-documents.d.ts.map +1 -0
  85. package/dist/schema/payment-instruments.d.ts +1 -1
  86. package/dist/schema/payment-instruments.d.ts.map +1 -0
  87. package/dist/schema/payment-processing.d.ts +3 -3
  88. package/dist/schema/payment-processing.d.ts.map +1 -0
  89. package/dist/schema/payment-sessions.d.ts +3 -3
  90. package/dist/schema/payment-sessions.d.ts.map +1 -0
  91. package/dist/schema/receivables.d.ts +3 -3
  92. package/dist/schema/receivables.d.ts.map +1 -0
  93. package/dist/schema/relations.d.ts.map +1 -0
  94. package/dist/schema/supplier-invoices.d.ts +5 -5
  95. package/dist/schema/supplier-invoices.d.ts.map +1 -0
  96. package/dist/schema/tax.d.ts +1 -1
  97. package/dist/schema/tax.d.ts.map +1 -0
  98. package/dist/schema/vouchers.d.ts +444 -0
  99. package/dist/schema/vouchers.d.ts.map +1 -0
  100. package/dist/schema/vouchers.js +64 -0
  101. package/dist/schema.d.ts.map +1 -0
  102. package/dist/service-accountant-shares.d.ts.map +1 -0
  103. package/dist/service-action-ledger-accounting.d.ts.map +1 -0
  104. package/dist/service-action-ledger-booking-payments.d.ts.map +1 -0
  105. package/dist/service-action-ledger-bookings.d.ts.map +1 -0
  106. package/dist/service-action-ledger-payment-authorizations.d.ts.map +1 -0
  107. package/dist/service-action-ledger-payment-sessions.d.ts.map +1 -0
  108. package/dist/service-action-ledger-supplier-invoices.d.ts.map +1 -0
  109. package/dist/service-action-ledger-supplier-payments.d.ts.map +1 -0
  110. package/dist/service-action-ledger.d.ts.map +1 -0
  111. package/dist/service-aggregates.d.ts.map +1 -0
  112. package/dist/service-booking-billing.d.ts +111 -111
  113. package/dist/service-booking-billing.d.ts.map +1 -0
  114. package/dist/service-booking-create.d.ts +11 -11
  115. package/dist/service-booking-create.d.ts.map +1 -0
  116. package/dist/service-booking-guarantees.d.ts +34 -34
  117. package/dist/service-booking-guarantees.d.ts.map +1 -0
  118. package/dist/service-booking-item-billing.d.ts +21 -21
  119. package/dist/service-booking-item-billing.d.ts.map +1 -0
  120. package/dist/service-booking-payment-schedules.d.ts +56 -56
  121. package/dist/service-booking-payment-schedules.d.ts.map +1 -0
  122. package/dist/service-bookings-dual-create.d.ts +22 -22
  123. package/dist/service-bookings-dual-create.d.ts.map +1 -0
  124. package/dist/service-boundary-sql.d.ts.map +1 -0
  125. package/dist/service-cost-categories.d.ts.map +1 -0
  126. package/dist/service-documents.d.ts.map +1 -0
  127. package/dist/service-invoice-artifacts.d.ts +24 -24
  128. package/dist/service-invoice-artifacts.d.ts.map +1 -0
  129. package/dist/service-invoice-core.d.ts +28 -28
  130. package/dist/service-invoice-core.d.ts.map +1 -0
  131. package/dist/service-invoice-credit-notes.d.ts +10 -10
  132. package/dist/service-invoice-credit-notes.d.ts.map +1 -0
  133. package/dist/service-invoice-from-booking.d.ts +19 -19
  134. package/dist/service-invoice-from-booking.d.ts.map +1 -0
  135. package/dist/service-invoice-line-items.d.ts +10 -10
  136. package/dist/service-invoice-line-items.d.ts.map +1 -0
  137. package/dist/service-invoice-numbering.d.ts +28 -28
  138. package/dist/service-invoice-numbering.d.ts.map +1 -0
  139. package/dist/service-invoice-payments.d.ts +20 -20
  140. package/dist/service-invoice-payments.d.ts.map +1 -0
  141. package/dist/service-invoices.d.ts +87 -87
  142. package/dist/service-invoices.d.ts.map +1 -0
  143. package/dist/service-issue.d.ts +38 -38
  144. package/dist/service-issue.d.ts.map +1 -0
  145. package/dist/service-payment-authorizations.d.ts +26 -26
  146. package/dist/service-payment-authorizations.d.ts.map +1 -0
  147. package/dist/service-payment-instruments.d.ts +10 -10
  148. package/dist/service-payment-instruments.d.ts.map +1 -0
  149. package/dist/service-payment-processing.d.ts +77 -77
  150. package/dist/service-payment-processing.d.ts.map +1 -0
  151. package/dist/service-payment-session-completion.d.ts +3 -3
  152. package/dist/service-payment-session-completion.d.ts.map +1 -0
  153. package/dist/service-payment-sessions.d.ts +38 -38
  154. package/dist/service-payment-sessions.d.ts.map +1 -0
  155. package/dist/service-profitability.d.ts.map +1 -0
  156. package/dist/service-public.d.ts +16 -16
  157. package/dist/service-public.d.ts.map +1 -0
  158. package/dist/service-reference-data.d.ts +23 -23
  159. package/dist/service-reference-data.d.ts.map +1 -0
  160. package/dist/service-rendition-wait.d.ts +1 -1
  161. package/dist/service-rendition-wait.d.ts.map +1 -0
  162. package/dist/service-reports.d.ts.map +1 -0
  163. package/dist/service-settlement.d.ts.map +1 -0
  164. package/dist/service-shared.d.ts +4 -4
  165. package/dist/service-shared.d.ts.map +1 -0
  166. package/dist/service-supplier-invoices.d.ts +20 -20
  167. package/dist/service-supplier-invoices.d.ts.map +1 -0
  168. package/dist/service-supplier-payments.d.ts +16 -16
  169. package/dist/service-supplier-payments.d.ts.map +1 -0
  170. package/dist/service-travel-credits.d.ts +10 -10
  171. package/dist/service-vouchers-migration.d.ts +51 -0
  172. package/dist/service-vouchers-migration.d.ts.map +1 -0
  173. package/dist/service-vouchers-migration.js +154 -0
  174. package/dist/service-vouchers.d.ts +157 -0
  175. package/dist/service-vouchers.d.ts.map +1 -0
  176. package/dist/service-vouchers.js +191 -0
  177. package/dist/service.d.ts +376 -376
  178. package/dist/service.d.ts.map +1 -0
  179. package/dist/stale-booking-holds-runtime.d.ts.map +1 -0
  180. package/dist/tools.d.ts +21 -21
  181. package/dist/tools.d.ts.map +1 -0
  182. package/dist/validation-billing.d.ts.map +1 -0
  183. package/dist/validation-payments.d.ts.map +1 -0
  184. package/dist/validation-public.d.ts.map +1 -0
  185. package/dist/validation-shared.d.ts.map +1 -0
  186. package/dist/validation-vouchers.d.ts +2 -0
  187. package/dist/validation-vouchers.d.ts.map +1 -0
  188. package/dist/validation-vouchers.js +1 -0
  189. package/dist/validation.d.ts.map +1 -0
  190. package/dist/voyant.d.ts +2 -1
  191. package/dist/voyant.d.ts.map +1 -0
  192. package/dist/voyant.js +43 -7
  193. package/openapi/admin/booking-tax-preview.json +33 -0
  194. package/openapi/admin/{booking-tax.json → booking-tax-settings.json} +7 -32
  195. package/package.json +205 -170
  196. package/LICENSE +0 -201
@@ -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
+ status: "void" | "expired" | "active" | "redeemed";
80
+ notes: string | null;
81
+ createdAt: Date;
82
+ updatedAt: Date;
83
+ currency: string;
84
+ expiresAt: Date | null;
85
+ code: string;
86
+ seriesCode: string | null;
87
+ initialAmountCents: number;
88
+ remainingAmountCents: number;
89
+ issuedToPersonId: string | null;
90
+ issuedToOrganizationId: string | null;
91
+ sourceType: "manual" | "refund" | "cancellation_credit" | "gift" | "promo";
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
+ amountCents: number;
150
+ paymentId: string | null;
151
+ voucherId: string;
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
+ };