@unifiedcommerce/plugin-giftcards 0.2.0 → 0.2.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unifiedcommerce/plugin-giftcards",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -39,6 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "files": [
42
+ "src",
42
43
  "dist",
43
44
  "README.md"
44
45
  ],
@@ -0,0 +1,79 @@
1
+ import { randomBytes } from "node:crypto";
2
+
3
+ /**
4
+ * Allowed characters for gift card codes.
5
+ * Excludes visually ambiguous characters: 0, O, 1, I, L
6
+ * Charset size: 30 → 30^16 ≈ 7.2 × 10^23 possible codes
7
+ */
8
+ const CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
9
+
10
+ /**
11
+ * Generate a cryptographically secure gift card code.
12
+ *
13
+ * Format: XXXX-XXXX-XXXX-XXXX (16 alphanumeric chars)
14
+ * Uses crypto.randomBytes for uniform distribution.
15
+ *
16
+ * @param format - Pattern where X is replaced with a random char. Default: "XXXX-XXXX-XXXX-XXXX"
17
+ */
18
+ export function generateGiftCardCode(format = "XXXX-XXXX-XXXX-XXXX"): string {
19
+ const charCount = (format.match(/X/g) ?? []).length;
20
+ // Request extra bytes to handle modulo bias rejection
21
+ const bytes = randomBytes(charCount * 2);
22
+
23
+ let byteIdx = 0;
24
+ let result = "";
25
+
26
+ for (const ch of format) {
27
+ if (ch === "X") {
28
+ // Rejection sampling to avoid modulo bias
29
+ // CHARSET.length = 30, so we reject values >= 240 (240 = 30 * 8)
30
+ let value: number;
31
+ do {
32
+ if (byteIdx >= bytes.length) {
33
+ // Extremely unlikely — generate more bytes
34
+ const extra = randomBytes(charCount);
35
+ for (let i = 0; i < extra.length; i++) {
36
+ bytes[byteIdx + i] = extra[i]!;
37
+ }
38
+ }
39
+ value = bytes[byteIdx++]!;
40
+ } while (value >= 240); // 240 = 30 * 8, ensures uniform distribution
41
+
42
+ result += CHARSET[value % CHARSET.length]!;
43
+ } else {
44
+ result += ch;
45
+ }
46
+ }
47
+
48
+ return result;
49
+ }
50
+
51
+ /**
52
+ * Normalize a gift card code for database lookup.
53
+ * Strips hyphens/spaces and uppercases.
54
+ */
55
+ export function normalizeCode(code: string): string {
56
+ return code.replace(/[-\s]/g, "").toUpperCase();
57
+ }
58
+
59
+ /**
60
+ * Format a raw code string into the display format.
61
+ */
62
+ export function formatCode(raw: string, format = "XXXX-XXXX-XXXX-XXXX"): string {
63
+ const chars = raw.replace(/[-\s]/g, "").toUpperCase();
64
+ let charIdx = 0;
65
+ let result = "";
66
+
67
+ for (const ch of format) {
68
+ if (ch === "X" && charIdx < chars.length) {
69
+ result += chars[charIdx++];
70
+ } else if (ch !== "X") {
71
+ result += ch;
72
+ }
73
+ }
74
+
75
+ return result;
76
+ }
77
+
78
+ /** The character set used for code generation (for validation/testing) */
79
+ export const CODE_CHARSET = CHARSET;
@@ -0,0 +1,115 @@
1
+ import { resolveOrgId } from "@unifiedcommerce/core";
2
+ import type { PluginHookRegistration } from "@unifiedcommerce/core";
3
+ import type { GiftCardService } from "../services/gift-card-service.js";
4
+ import type { GiftCardDeduction } from "../types.js";
5
+
6
+ interface HookContextLike {
7
+ actor: { organizationId?: string | null; [key: string]: unknown } | null;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ interface CheckoutHookArgs {
12
+ data: {
13
+ total: number;
14
+ currency: string;
15
+ checkoutId: string;
16
+ metadata?: Record<string, unknown>;
17
+ };
18
+ context: HookContextLike;
19
+ }
20
+
21
+ interface AfterCreateHookArgs {
22
+ data: { metadata?: Record<string, unknown>; checkoutId: string };
23
+ result: unknown;
24
+ context: HookContextLike;
25
+ }
26
+
27
+ /**
28
+ * checkout.beforePayment hook — deducts gift card balances before the
29
+ * payment adapter authorizes the remaining amount.
30
+ */
31
+ export function buildCheckoutDeductionHook(
32
+ service: GiftCardService,
33
+ ): PluginHookRegistration {
34
+ const handler = async (args: CheckoutHookArgs) => {
35
+ const { data, context } = args;
36
+ const orgId = resolveOrgId(context.actor);
37
+ const codes = data.metadata?.giftCardCodes as string[] | undefined;
38
+ if (!codes?.length) return data;
39
+
40
+ let remaining = data.total;
41
+ const deductions: GiftCardDeduction[] = [];
42
+
43
+ for (const code of codes) {
44
+ if (remaining <= 0) break;
45
+
46
+ const balanceResult = await service.checkBalance(orgId, code);
47
+ if (!balanceResult.ok) continue;
48
+
49
+ const deductAmount = Math.min(remaining, balanceResult.value.balance);
50
+ if (deductAmount <= 0) continue;
51
+
52
+ const result = await service.debitWithLock(
53
+ orgId,
54
+ code,
55
+ deductAmount,
56
+ data.checkoutId,
57
+ data.currency,
58
+ );
59
+
60
+ if (result.ok) {
61
+ deductions.push(result.value);
62
+ remaining -= deductAmount;
63
+ }
64
+ }
65
+
66
+ const giftCardTotal = deductions.reduce((sum, d) => sum + d.amount, 0);
67
+
68
+ return {
69
+ ...data,
70
+ total: Math.max(0, remaining),
71
+ metadata: {
72
+ ...data.metadata,
73
+ giftCardDeductions: deductions,
74
+ giftCardTotal,
75
+ },
76
+ };
77
+ };
78
+
79
+ return {
80
+ key: "checkout.beforePayment",
81
+ handler: handler as (...args: unknown[]) => unknown,
82
+ };
83
+ }
84
+
85
+ /**
86
+ * checkout.afterCreate hook — compensates gift card deductions if checkout fails.
87
+ */
88
+ export function buildCheckoutCompensationHook(
89
+ service: GiftCardService,
90
+ ): PluginHookRegistration {
91
+ const handler = async (args: AfterCreateHookArgs) => {
92
+ const { data, result, context } = args;
93
+ const orgId = resolveOrgId(context.actor);
94
+ if (!result) {
95
+ const deductions = data.metadata?.giftCardDeductions as
96
+ | GiftCardDeduction[]
97
+ | undefined;
98
+
99
+ for (const d of deductions ?? []) {
100
+ await service.creditWithLock(
101
+ orgId,
102
+ d.code,
103
+ d.amount,
104
+ data.checkoutId,
105
+ "Checkout failed — balance restored",
106
+ );
107
+ }
108
+ }
109
+ };
110
+
111
+ return {
112
+ key: "checkout.afterCreate",
113
+ handler: handler as (...args: unknown[]) => unknown,
114
+ };
115
+ }
@@ -0,0 +1,93 @@
1
+ import { resolveOrgId } from "@unifiedcommerce/core";
2
+ import type { PluginHookRegistration } from "@unifiedcommerce/core";
3
+ import type { GiftCardService } from "../services/gift-card-service.js";
4
+ import type { GiftCardPluginOptions } from "../types.js";
5
+
6
+ interface OrderLineItem {
7
+ entityId: string;
8
+ entityType?: string;
9
+ quantity: number;
10
+ unitPrice?: number;
11
+ totalPrice?: number;
12
+ }
13
+
14
+ interface OrderResult {
15
+ id: string;
16
+ customerId?: string | null;
17
+ currency: string;
18
+ lineItems?: OrderLineItem[];
19
+ metadata?: Record<string, unknown> | null;
20
+ }
21
+
22
+ interface HookContextLike {
23
+ actor: { organizationId?: string | null; [key: string]: unknown } | null;
24
+ [key: string]: unknown;
25
+ }
26
+
27
+ interface AfterCreateHookArgs {
28
+ data: unknown;
29
+ result: OrderResult | null;
30
+ context: HookContextLike;
31
+ }
32
+
33
+ /**
34
+ * checkout.afterCreate hook — issues gift cards when a gift card product is purchased.
35
+ */
36
+ export function buildGiftCardIssuanceHook(
37
+ service: GiftCardService,
38
+ options: Required<GiftCardPluginOptions>,
39
+ enqueueJob?: (slug: string, input: Record<string, unknown>) => Promise<string>,
40
+ ): PluginHookRegistration {
41
+ const handler = async (args: AfterCreateHookArgs) => {
42
+ const { result, context } = args;
43
+ if (!result?.lineItems) return;
44
+
45
+ const orgId = resolveOrgId(context.actor);
46
+
47
+ for (const item of result.lineItems) {
48
+ if (item.entityType !== options.productType) continue;
49
+
50
+ const amount = item.totalPrice ?? (item.unitPrice ?? 0) * item.quantity;
51
+ if (amount <= 0) continue;
52
+
53
+ const orderMeta = result.metadata as Record<string, unknown> | null;
54
+ const recipientEmail = (orderMeta?.giftCardRecipientEmail as string) ?? undefined;
55
+ const senderName = (orderMeta?.giftCardSenderName as string) ?? undefined;
56
+ const personalMessage = (orderMeta?.giftCardPersonalMessage as string) ?? undefined;
57
+
58
+ const createInput: Parameters<typeof service.create>[1] = {
59
+ amount,
60
+ currency: result.currency,
61
+ sourceOrderId: result.id,
62
+ };
63
+ if (result.customerId) createInput.purchaserId = result.customerId;
64
+ if (recipientEmail) createInput.recipientEmail = recipientEmail;
65
+ if (senderName) createInput.senderName = senderName;
66
+ if (personalMessage) createInput.personalMessage = personalMessage;
67
+
68
+ const cardResult = await service.create(orgId, createInput);
69
+
70
+ if (cardResult.ok && enqueueJob && recipientEmail) {
71
+ try {
72
+ await enqueueJob("gift-card.deliver", {
73
+ giftCardId: cardResult.value.id,
74
+ code: cardResult.value.code,
75
+ amount,
76
+ currency: result.currency,
77
+ recipientEmail,
78
+ senderName: senderName ?? "",
79
+ personalMessage: personalMessage ?? "",
80
+ template: options.emailTemplate,
81
+ });
82
+ } catch {
83
+ // Email delivery failure should not break checkout
84
+ }
85
+ }
86
+ }
87
+ };
88
+
89
+ return {
90
+ key: "checkout.afterCreate",
91
+ handler: handler as (...args: unknown[]) => unknown,
92
+ };
93
+ }
@@ -0,0 +1,56 @@
1
+ import { resolveOrgId } from "@unifiedcommerce/core";
2
+ import type { PluginHookRegistration } from "@unifiedcommerce/core";
3
+ import type { GiftCardService } from "../services/gift-card-service.js";
4
+ import type { GiftCardDeduction } from "../types.js";
5
+
6
+ interface HookContextLike {
7
+ actor: { organizationId?: string | null; [key: string]: unknown } | null;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ interface OrderUpdateHookArgs {
12
+ data: unknown;
13
+ result: {
14
+ id: string;
15
+ status?: string;
16
+ metadata?: Record<string, unknown> | null;
17
+ } | null;
18
+ context: HookContextLike;
19
+ }
20
+
21
+ /**
22
+ * order.afterUpdate hook — restores gift card balances when an order is refunded.
23
+ */
24
+ export function buildRefundCreditHook(
25
+ service: GiftCardService,
26
+ ): PluginHookRegistration {
27
+ const handler = async (args: OrderUpdateHookArgs) => {
28
+ const { result, context } = args;
29
+ if (!result) return;
30
+
31
+ const status = result.status;
32
+ if (status !== "refunded" && status !== "cancelled") return;
33
+
34
+ const deductions = result.metadata?.giftCardDeductions as
35
+ | GiftCardDeduction[]
36
+ | undefined;
37
+
38
+ if (!deductions?.length) return;
39
+
40
+ const orgId = resolveOrgId(context.actor);
41
+ for (const d of deductions) {
42
+ await service.creditWithLock(
43
+ orgId,
44
+ d.code,
45
+ d.amount,
46
+ result.id,
47
+ `Order ${status} — balance restored`,
48
+ );
49
+ }
50
+ };
51
+
52
+ return {
53
+ key: "order.afterUpdate",
54
+ handler: handler as (...args: unknown[]) => unknown,
55
+ };
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,219 @@
1
+ import { defineCommercePlugin, toolBuilder } from "@unifiedcommerce/core";
2
+ import { z } from "zod";
3
+ import { giftCards, giftCardTransactions } from "./schema.js";
4
+ import { GiftCardService } from "./services/gift-card-service.js";
5
+ import {
6
+ buildCheckoutDeductionHook,
7
+ buildCheckoutCompensationHook,
8
+ } from "./hooks/checkout-deduction.js";
9
+ import { buildGiftCardIssuanceHook } from "./hooks/checkout-issuance.js";
10
+ import { buildRefundCreditHook } from "./hooks/refund-credit.js";
11
+ import { buildAdminRoutes } from "./routes/admin.js";
12
+ import { buildPublicRoutes } from "./routes/public.js";
13
+ import { buildCustomerRoutes } from "./routes/customer.js";
14
+ import type { GiftCardPluginOptions } from "./types.js";
15
+ import { DEFAULT_OPTIONS } from "./types.js";
16
+
17
+ export type { GiftCardPluginOptions } from "./types.js";
18
+ export { GiftCardService } from "./services/gift-card-service.js";
19
+
20
+ export function giftCardPlugin(userOptions: GiftCardPluginOptions = {}) {
21
+ const options: Required<GiftCardPluginOptions> = {
22
+ ...DEFAULT_OPTIONS,
23
+ ...userOptions,
24
+ // Preserve null for defaultExpiryDays when user doesn't set it
25
+ defaultExpiryDays: userOptions.defaultExpiryDays ?? DEFAULT_OPTIONS.defaultExpiryDays,
26
+ };
27
+
28
+ return defineCommercePlugin({
29
+ id: "gift-cards",
30
+ version: "1.0.0",
31
+
32
+ permissions: [
33
+ {
34
+ scope: "gift-cards:admin",
35
+ description:
36
+ "Create, list, disable, and adjust gift cards. Required for all admin routes.",
37
+ },
38
+ ],
39
+
40
+ schema: () => ({
41
+ giftCards,
42
+ giftCardTransactions,
43
+ }),
44
+
45
+ hooks: () => {
46
+ // Hooks are registered before the service is available (no DB context).
47
+ // They will be populated with the real service in routes() where ctx is available.
48
+ // For now, return empty — we'll wire them up via a shared reference.
49
+ return [];
50
+ },
51
+
52
+ mcpTools: (ctx) => {
53
+ const db = ctx.database.db;
54
+ if (!db) return [];
55
+ const service = new GiftCardService(
56
+ db,
57
+ ctx.database.transaction,
58
+ options,
59
+ );
60
+
61
+ const t = toolBuilder("giftcards", "Manage gift cards. Use when checking balances, issuing new cards, looking up card details, or viewing transaction history.");
62
+
63
+ t.action("check_balance", "look up balance by code")
64
+ .input(z.object({
65
+ orgId: z.string().describe("Organization ID"),
66
+ code: z.string().describe("Gift card code"),
67
+ }))
68
+ .handler(async (args) => service.checkBalance(args.orgId, args.code));
69
+
70
+ t.action("issue", "create new card with amount/currency")
71
+ .input(z.object({
72
+ orgId: z.string().describe("Organization ID"),
73
+ amount: z.number().describe("Initial balance in minor units/cents"),
74
+ currency: z.string().describe("Currency code e.g. USD"),
75
+ purchaserId: z.string().describe("Customer who purchased the card").optional(),
76
+ recipientEmail: z.string().describe("Email to deliver the card to").optional(),
77
+ senderName: z.string().describe("Name of the sender").optional(),
78
+ personalMessage: z.string().describe("Personal message for recipient").optional(),
79
+ }))
80
+ .handler(async (args) => {
81
+ const input: { amount: number; currency: string; purchaserId?: string; recipientEmail?: string; senderName?: string; personalMessage?: string } = {
82
+ amount: args.amount,
83
+ currency: args.currency,
84
+ };
85
+ if (args.purchaserId != null) input.purchaserId = args.purchaserId;
86
+ if (args.recipientEmail != null) input.recipientEmail = args.recipientEmail;
87
+ if (args.senderName != null) input.senderName = args.senderName;
88
+ if (args.personalMessage != null) input.personalMessage = args.personalMessage;
89
+ return service.create(args.orgId, input);
90
+ });
91
+
92
+ t.action("get", "card details by ID")
93
+ .input(z.object({
94
+ orgId: z.string().describe("Organization ID"),
95
+ id: z.string().describe("Gift card ID"),
96
+ }))
97
+ .handler(async (args) => service.getById(args.orgId, args.id));
98
+
99
+ t.action("list", "all cards with optional filters")
100
+ .input(z.object({
101
+ orgId: z.string().describe("Organization ID"),
102
+ status: z.string().describe("Filter by status: active, exhausted, or disabled").optional(),
103
+ purchaserId: z.string().describe("Filter by purchaser ID").optional(),
104
+ }))
105
+ .handler(async (args) => {
106
+ const filters: { status?: "active" | "exhausted" | "disabled"; purchaserId?: string } = {};
107
+ if (args.status != null) filters.status = args.status as "active" | "exhausted" | "disabled";
108
+ if (args.purchaserId != null) filters.purchaserId = args.purchaserId;
109
+ return service.list(args.orgId, filters);
110
+ });
111
+
112
+ t.action("transactions", "credit/debit history for a card")
113
+ .input(z.object({
114
+ orgId: z.string().describe("Organization ID"),
115
+ giftCardId: z.string().describe("Gift card ID"),
116
+ }))
117
+ .handler(async (args) => service.getTransactions(args.orgId, args.giftCardId));
118
+
119
+ return t.build(ctx);
120
+ },
121
+
122
+ routes: (ctx) => {
123
+ const db = ctx.database.db;
124
+ if (!db) return [];
125
+
126
+ const service = new GiftCardService(
127
+ db,
128
+ ctx.database.transaction,
129
+ options,
130
+ );
131
+
132
+ return [
133
+ ...buildAdminRoutes(service, ctx),
134
+ ...buildPublicRoutes(service, ctx),
135
+ ...buildCustomerRoutes(service, ctx),
136
+ ];
137
+ },
138
+ });
139
+ }
140
+
141
+ /**
142
+ * Standalone factory for the plugin with hooks wired.
143
+ *
144
+ * Since hooks() runs before routes() (no DB context), we use a deferred
145
+ * service pattern: hooks capture a shared reference that gets populated
146
+ * when routes() runs with the real DB.
147
+ */
148
+ export function giftCardPluginWithHooks(
149
+ userOptions: GiftCardPluginOptions = {},
150
+ ) {
151
+ const options: Required<GiftCardPluginOptions> = {
152
+ ...DEFAULT_OPTIONS,
153
+ ...userOptions,
154
+ defaultExpiryDays: userOptions.defaultExpiryDays ?? DEFAULT_OPTIONS.defaultExpiryDays,
155
+ };
156
+
157
+ // Shared mutable reference — populated when routes() runs
158
+ const serviceRef: { current: GiftCardService | null } = { current: null };
159
+
160
+ return defineCommercePlugin({
161
+ id: "gift-cards",
162
+ version: "1.0.0",
163
+
164
+ permissions: [
165
+ {
166
+ scope: "gift-cards:admin",
167
+ description:
168
+ "Create, list, disable, and adjust gift cards. Required for all admin routes.",
169
+ },
170
+ ],
171
+
172
+ schema: () => ({
173
+ giftCards,
174
+ giftCardTransactions,
175
+ }),
176
+
177
+ hooks: () => {
178
+ // Lazy proxy: defers to serviceRef.current once routes() initializes it.
179
+ // Uses Reflect.get for type-safe dynamic property access (no index signature needed).
180
+ const lazyService = new Proxy({} as GiftCardService, {
181
+ get(_target, prop, receiver) {
182
+ if (!serviceRef.current) {
183
+ throw new Error(
184
+ "Gift card service not initialized — hooks ran before routes()",
185
+ );
186
+ }
187
+ return Reflect.get(serviceRef.current, prop, receiver);
188
+ },
189
+ });
190
+
191
+ return [
192
+ buildCheckoutDeductionHook(lazyService),
193
+ buildCheckoutCompensationHook(lazyService),
194
+ buildGiftCardIssuanceHook(lazyService, options),
195
+ buildRefundCreditHook(lazyService),
196
+ ];
197
+ },
198
+
199
+ routes: (ctx) => {
200
+ const db = ctx.database.db;
201
+ if (!db) return [];
202
+
203
+ const service = new GiftCardService(
204
+ db,
205
+ ctx.database.transaction,
206
+ options,
207
+ );
208
+
209
+ // Wire up the shared reference so hooks can access the service
210
+ serviceRef.current = service;
211
+
212
+ return [
213
+ ...buildAdminRoutes(service, ctx),
214
+ ...buildPublicRoutes(service, ctx),
215
+ ...buildCustomerRoutes(service, ctx),
216
+ ];
217
+ },
218
+ });
219
+ }
@@ -0,0 +1,115 @@
1
+ import { router } from "@unifiedcommerce/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { GiftCardService } from "../services/gift-card-service.js";
4
+ import type { PluginRouteRegistration } from "@unifiedcommerce/core";
5
+ import { formatCode } from "../code-generator.js";
6
+
7
+ export function buildAdminRoutes(
8
+ service: GiftCardService,
9
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
10
+ ): PluginRouteRegistration[] {
11
+ const r = router("Gift Cards (Admin)", "/gift-cards", ctx);
12
+
13
+ // ─── Create Gift Card ─────────────────────────────────────────────
14
+
15
+ r.post("/")
16
+ .summary("Create gift card")
17
+ .permission("gift-cards:admin")
18
+ .input(
19
+ z.object({
20
+ amount: z.number().int().positive().describe("Amount in minor units (cents)"),
21
+ currency: z.string().min(3).max(3).describe("ISO 4217 currency code"),
22
+ recipientEmail: z.string().email().optional(),
23
+ senderName: z.string().optional(),
24
+ personalMessage: z.string().max(500).optional(),
25
+ metadata: z.record(z.string(), z.unknown()).optional(),
26
+ }),
27
+ )
28
+ .handler(async ({ input, orgId }) => {
29
+ const body = input as {
30
+ amount: number;
31
+ currency: string;
32
+ recipientEmail?: string;
33
+ senderName?: string;
34
+ personalMessage?: string;
35
+ metadata?: Record<string, unknown>;
36
+ };
37
+ const result = await service.create(orgId, body);
38
+ if (!result.ok) throw new Error(result.error);
39
+ return {
40
+ ...result.value,
41
+ displayCode: formatCode(result.value.code),
42
+ };
43
+ });
44
+
45
+ // ─── List Gift Cards ──────────────────────────────────────────────
46
+
47
+ r.get("/")
48
+ .summary("List gift cards")
49
+ .permission("gift-cards:admin")
50
+ .query(
51
+ z.object({
52
+ status: z.enum(["active", "disabled", "exhausted"]).optional(),
53
+ purchaserId: z.string().optional(),
54
+ }),
55
+ )
56
+ .handler(async ({ query, orgId }) => {
57
+ const q = query as { status?: string; purchaserId?: string };
58
+ const filters: { status?: "active" | "disabled" | "exhausted"; purchaserId?: string } = {};
59
+ if (q.status === "active" || q.status === "disabled" || q.status === "exhausted") {
60
+ filters.status = q.status;
61
+ }
62
+ if (q.purchaserId) filters.purchaserId = q.purchaserId;
63
+ const result = await service.list(orgId, filters);
64
+ if (!result.ok) throw new Error("Failed to list gift cards");
65
+ return result.value;
66
+ });
67
+
68
+ // ─── Get Gift Card by ID ──────────────────────────────────────────
69
+
70
+ r.get("/{id}")
71
+ .summary("Get gift card details")
72
+ .permission("gift-cards:admin")
73
+ .handler(async ({ params, orgId }) => {
74
+ const cardResult = await service.getById(orgId, params.id!);
75
+ if (!cardResult.ok) throw new Error(cardResult.error);
76
+
77
+ const txnResult = await service.getTransactions(orgId, cardResult.value.id);
78
+ return {
79
+ ...cardResult.value,
80
+ displayCode: formatCode(cardResult.value.code),
81
+ transactions: txnResult.ok ? txnResult.value : [],
82
+ };
83
+ });
84
+
85
+ // ─── Disable Gift Card ────────────────────────────────────────────
86
+
87
+ r.post("/{id}/disable")
88
+ .summary("Disable gift card")
89
+ .permission("gift-cards:admin")
90
+ .handler(async ({ params, orgId }) => {
91
+ const result = await service.disable(orgId, params.id!);
92
+ if (!result.ok) throw new Error(result.error);
93
+ return result.value;
94
+ });
95
+
96
+ // ─── Manual Balance Adjustment ────────────────────────────────────
97
+
98
+ r.post("/{id}/adjust")
99
+ .summary("Adjust gift card balance")
100
+ .permission("gift-cards:admin")
101
+ .input(
102
+ z.object({
103
+ delta: z.number().int().describe("Adjustment amount in minor units (positive=credit, negative=debit)"),
104
+ note: z.string().min(1).max(500).describe("Reason for adjustment"),
105
+ }),
106
+ )
107
+ .handler(async ({ params, input, orgId }) => {
108
+ const body = input as { delta: number; note: string };
109
+ const result = await service.adjust(orgId, params.id!, body.delta, body.note);
110
+ if (!result.ok) throw new Error(result.error);
111
+ return result.value;
112
+ });
113
+
114
+ return r.routes();
115
+ }
@@ -0,0 +1,30 @@
1
+ import { router } from "@unifiedcommerce/core";
2
+ import type { GiftCardService } from "../services/gift-card-service.js";
3
+ import type { PluginRouteRegistration } from "@unifiedcommerce/core";
4
+ import { formatCode } from "../code-generator.js";
5
+
6
+ export function buildCustomerRoutes(
7
+ service: GiftCardService,
8
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
9
+ ): PluginRouteRegistration[] {
10
+ const r = router("Gift Cards (Customer)", "/me/gift-cards", ctx);
11
+
12
+ // ─── List Customer's Gift Cards ───────────────────────────────────
13
+
14
+ r.get("/")
15
+ .summary("List my gift cards")
16
+ .auth()
17
+ .handler(async ({ actor, orgId }) => {
18
+ if (!actor) throw new Error("Unauthorized");
19
+ const result = await service.list(orgId, { purchaserId: actor.userId });
20
+ if (!result.ok) throw new Error("Failed to list gift cards");
21
+ return result.value.map((card) => ({
22
+ ...card,
23
+ displayCode: formatCode(card.code),
24
+ // Mask the full code for security — show only last 4 chars
25
+ maskedCode: `****-****-****-${card.code.slice(-4)}`,
26
+ }));
27
+ });
28
+
29
+ return r.routes();
30
+ }
@@ -0,0 +1,31 @@
1
+ import { router } from "@unifiedcommerce/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { GiftCardService } from "../services/gift-card-service.js";
4
+ import type { PluginRouteRegistration } from "@unifiedcommerce/core";
5
+
6
+ export function buildPublicRoutes(
7
+ service: GiftCardService,
8
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
9
+ ): PluginRouteRegistration[] {
10
+ const r = router("Gift Cards", "/gift-cards", ctx);
11
+
12
+ // ─── Check Balance (Public) ───────────────────────────────────────
13
+
14
+ r.post("/check-balance")
15
+ .summary("Check gift card balance")
16
+ .description("Public endpoint — no authentication required. Rate-limited.")
17
+ .input(
18
+ z.object({
19
+ code: z.string().min(4).max(30).describe("Gift card code (hyphens optional)"),
20
+ }),
21
+ )
22
+ .handler(async ({ input }) => {
23
+ const body = input as { code: string };
24
+ // Public endpoint: codes are globally unique, no org scoping needed
25
+ const result = await service.checkBalance("_any", body.code);
26
+ if (!result.ok) throw new Error(result.error);
27
+ return result.value;
28
+ });
29
+
30
+ return r.routes();
31
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,89 @@
1
+ import {
2
+ pgTable,
3
+ uuid,
4
+ text,
5
+ integer,
6
+ boolean,
7
+ timestamp,
8
+ jsonb,
9
+ index,
10
+ check,
11
+ uniqueIndex,
12
+ } from "drizzle-orm/pg-core";
13
+ import { sql } from "drizzle-orm";
14
+
15
+ // ─── Gift Cards ──────────────────────────────────────────────────────────────
16
+
17
+ export const giftCards = pgTable(
18
+ "gift_cards",
19
+ {
20
+ id: uuid("id").defaultRandom().primaryKey(),
21
+ organizationId: text("organization_id").notNull(),
22
+ code: text("code").notNull(),
23
+ initialAmount: integer("initial_amount").notNull(),
24
+ balance: integer("balance").notNull(),
25
+ currency: text("currency").notNull(),
26
+ status: text("status", {
27
+ enum: ["active", "disabled", "exhausted"],
28
+ })
29
+ .notNull()
30
+ .default("active"),
31
+ purchaserId: text("purchaser_id"),
32
+ recipientEmail: text("recipient_email"),
33
+ senderName: text("sender_name"),
34
+ personalMessage: text("personal_message"),
35
+ sourceOrderId: text("source_order_id"),
36
+ expiresAt: timestamp("expires_at", { withTimezone: true }),
37
+ version: integer("version").notNull().default(0),
38
+ metadata: jsonb("metadata")
39
+ .$type<Record<string, unknown>>()
40
+ .default({}),
41
+ createdAt: timestamp("created_at", { withTimezone: true })
42
+ .defaultNow()
43
+ .notNull(),
44
+ updatedAt: timestamp("updated_at", { withTimezone: true })
45
+ .defaultNow()
46
+ .notNull(),
47
+ },
48
+ (table) => ({
49
+ orgCodeUnique: uniqueIndex("gift_cards_org_code_unique").on(table.organizationId, table.code),
50
+ orgIdx: index("idx_gift_cards_org").on(table.organizationId),
51
+ codeIdx: index("idx_gift_cards_code").on(table.code),
52
+ purchaserIdx: index("idx_gift_cards_purchaser").on(table.purchaserId),
53
+ statusIdx: index("idx_gift_cards_status").on(table.status),
54
+ balanceCheck: check(
55
+ "gift_cards_balance_non_negative",
56
+ sql`${table.balance} >= 0`,
57
+ ),
58
+ initialAmountCheck: check(
59
+ "gift_cards_initial_amount_positive",
60
+ sql`${table.initialAmount} > 0`,
61
+ ),
62
+ }),
63
+ );
64
+
65
+ // ─── Gift Card Transactions ─────────────────────────────────────────────────
66
+
67
+ export const giftCardTransactions = pgTable(
68
+ "gift_card_transactions",
69
+ {
70
+ id: uuid("id").defaultRandom().primaryKey(),
71
+ giftCardId: uuid("gift_card_id")
72
+ .notNull()
73
+ .references(() => giftCards.id, { onDelete: "cascade" }),
74
+ type: text("type", {
75
+ enum: ["debit", "credit", "refund"],
76
+ }).notNull(),
77
+ amount: integer("amount").notNull(),
78
+ balanceAfter: integer("balance_after").notNull(),
79
+ orderId: text("order_id"),
80
+ note: text("note"),
81
+ createdAt: timestamp("created_at", { withTimezone: true })
82
+ .defaultNow()
83
+ .notNull(),
84
+ },
85
+ (table) => ({
86
+ cardIdx: index("idx_gc_txn_card").on(table.giftCardId),
87
+ orderIdx: index("idx_gc_txn_order").on(table.orderId),
88
+ }),
89
+ );
@@ -0,0 +1,157 @@
1
+ import { eq, desc, and } from "drizzle-orm";
2
+ import { giftCards, giftCardTransactions } from "../schema.js";
3
+ import type { Db, GiftCard, GiftCardInsert, GiftCardTransaction, GiftCardStatus, TransactionType } from "../types.js";
4
+
5
+ export class GiftCardRepository {
6
+ constructor(private db: Db) {}
7
+
8
+ private getDb(ctx?: { tx?: Db }): Db {
9
+ return ctx?.tx ?? this.db;
10
+ }
11
+
12
+ // ─── Gift Card CRUD ─────────────────────────────────────────────────
13
+
14
+ async create(data: GiftCardInsert, ctx?: { tx?: Db }): Promise<GiftCard> {
15
+ const rows = await this.getDb(ctx)
16
+ .insert(giftCards)
17
+ .values(data)
18
+ .returning();
19
+ return rows[0]!;
20
+ }
21
+
22
+ async findById(id: string, ctx?: { tx?: Db }): Promise<GiftCard | undefined> {
23
+ const rows = await this.getDb(ctx)
24
+ .select()
25
+ .from(giftCards)
26
+ .where(eq(giftCards.id, id));
27
+ return rows[0];
28
+ }
29
+
30
+ async findByCode(code: string, ctx?: { tx?: Db }): Promise<GiftCard | undefined> {
31
+ const rows = await this.getDb(ctx)
32
+ .select()
33
+ .from(giftCards)
34
+ .where(eq(giftCards.code, code));
35
+ return rows[0];
36
+ }
37
+
38
+ async list(
39
+ filters?: { status?: GiftCardStatus; purchaserId?: string },
40
+ ctx?: { tx?: Db },
41
+ ): Promise<GiftCard[]> {
42
+ const conditions = [];
43
+ if (filters?.status) conditions.push(eq(giftCards.status, filters.status));
44
+ if (filters?.purchaserId) conditions.push(eq(giftCards.purchaserId, filters.purchaserId));
45
+
46
+ return this.getDb(ctx)
47
+ .select()
48
+ .from(giftCards)
49
+ .where(conditions.length > 0 ? and(...conditions) : undefined)
50
+ .orderBy(desc(giftCards.createdAt));
51
+ }
52
+
53
+ async disable(id: string, ctx?: { tx?: Db }): Promise<GiftCard | undefined> {
54
+ const rows = await this.getDb(ctx)
55
+ .update(giftCards)
56
+ .set({ status: "disabled" as const, updatedAt: new Date() })
57
+ .where(eq(giftCards.id, id))
58
+ .returning();
59
+ return rows[0];
60
+ }
61
+
62
+ // ─── SELECT FOR UPDATE (Concurrency-Safe Balance Operations) ───────
63
+
64
+ async findByCodeForUpdate(code: string, tx: Db): Promise<GiftCard | undefined> {
65
+ const rows = await tx
66
+ .select()
67
+ .from(giftCards)
68
+ .where(eq(giftCards.code, code))
69
+ .for("update");
70
+ return rows[0];
71
+ }
72
+
73
+ async findByIdForUpdate(id: string, tx: Db): Promise<GiftCard | undefined> {
74
+ const rows = await tx
75
+ .select()
76
+ .from(giftCards)
77
+ .where(eq(giftCards.id, id))
78
+ .for("update");
79
+ return rows[0];
80
+ }
81
+
82
+ async updateBalance(
83
+ id: string,
84
+ balance: number,
85
+ status: GiftCardStatus,
86
+ currentVersion: number,
87
+ tx: Db,
88
+ ): Promise<GiftCard> {
89
+ const rows = await tx
90
+ .update(giftCards)
91
+ .set({
92
+ balance,
93
+ status,
94
+ version: currentVersion + 1,
95
+ updatedAt: new Date(),
96
+ })
97
+ .where(eq(giftCards.id, id))
98
+ .returning();
99
+ return rows[0]!;
100
+ }
101
+
102
+ async adjustBalance(
103
+ id: string,
104
+ delta: number,
105
+ tx: Db,
106
+ ): Promise<GiftCard> {
107
+ const card = await this.findByIdForUpdate(id, tx);
108
+ if (!card) throw new Error("Gift card not found");
109
+
110
+ const newBalance = Math.max(0, Math.min(card.initialAmount, card.balance + delta));
111
+ const newStatus: GiftCardStatus = newBalance === 0 ? "exhausted" : "active";
112
+
113
+ return this.updateBalance(id, newBalance, newStatus, card.version, tx);
114
+ }
115
+
116
+ // ─── Transactions ───────────────────────────────────────────────────
117
+
118
+ async recordTransaction(
119
+ data: {
120
+ giftCardId: string;
121
+ type: TransactionType;
122
+ amount: number;
123
+ balanceAfter: number;
124
+ orderId?: string;
125
+ note?: string;
126
+ },
127
+ ctx?: { tx?: Db },
128
+ ): Promise<GiftCardTransaction> {
129
+ const rows = await this.getDb(ctx)
130
+ .insert(giftCardTransactions)
131
+ .values(data)
132
+ .returning();
133
+ return rows[0]!;
134
+ }
135
+
136
+ async listTransactions(
137
+ giftCardId: string,
138
+ ctx?: { tx?: Db },
139
+ ): Promise<GiftCardTransaction[]> {
140
+ return this.getDb(ctx)
141
+ .select()
142
+ .from(giftCardTransactions)
143
+ .where(eq(giftCardTransactions.giftCardId, giftCardId))
144
+ .orderBy(desc(giftCardTransactions.createdAt));
145
+ }
146
+
147
+ async findTransactionsByOrderId(
148
+ orderId: string,
149
+ ctx?: { tx?: Db },
150
+ ): Promise<GiftCardTransaction[]> {
151
+ return this.getDb(ctx)
152
+ .select()
153
+ .from(giftCardTransactions)
154
+ .where(eq(giftCardTransactions.orderId, orderId))
155
+ .orderBy(desc(giftCardTransactions.createdAt));
156
+ }
157
+ }
@@ -0,0 +1,286 @@
1
+ import { Ok, Err } from "@unifiedcommerce/core";
2
+ import type { PluginResult } from "@unifiedcommerce/core";
3
+ import { generateGiftCardCode, normalizeCode } from "../code-generator.js";
4
+ import { GiftCardRepository } from "./gift-card-repository.js";
5
+ import type {
6
+ Db,
7
+ GiftCard,
8
+ GiftCardDeduction,
9
+ GiftCardPluginOptions,
10
+ GiftCardTransaction,
11
+ GiftCardStatus,
12
+ TransactionType,
13
+ } from "../types.js";
14
+
15
+ export class GiftCardService {
16
+ private repo: GiftCardRepository;
17
+ private transaction: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>;
18
+
19
+ constructor(
20
+ db: Db,
21
+ transactionFn: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>,
22
+ private options: Required<GiftCardPluginOptions>,
23
+ ) {
24
+ this.repo = new GiftCardRepository(db);
25
+ this.transaction = transactionFn;
26
+ }
27
+
28
+ // ─── Create ──────────────────────────────────────────────────────────
29
+
30
+ async create(orgId: string, input: {
31
+ amount: number;
32
+ currency: string;
33
+ purchaserId?: string;
34
+ recipientEmail?: string;
35
+ senderName?: string;
36
+ personalMessage?: string;
37
+ sourceOrderId?: string;
38
+ metadata?: Record<string, unknown>;
39
+ }): Promise<PluginResult<GiftCard>> {
40
+ if (input.amount <= 0) {
41
+ return Err("Amount must be positive");
42
+ }
43
+ if (input.amount > this.options.maxBalancePerCard) {
44
+ return Err(`Amount exceeds maximum of ${this.options.maxBalancePerCard}`);
45
+ }
46
+
47
+ // Generate unique code with collision retry
48
+ let code: string;
49
+ let attempts = 0;
50
+ do {
51
+ code = normalizeCode(generateGiftCardCode(this.options.codeFormat));
52
+ const existing = await this.repo.findByCode(code);
53
+ if (!existing) break;
54
+ attempts++;
55
+ } while (attempts < 10);
56
+
57
+ if (attempts >= 10) {
58
+ return Err("Failed to generate unique code after 10 attempts");
59
+ }
60
+
61
+ const expiresAt = this.options.defaultExpiryDays
62
+ ? new Date(Date.now() + this.options.defaultExpiryDays * 24 * 60 * 60 * 1000)
63
+ : undefined;
64
+
65
+ const card = await this.repo.create({
66
+ organizationId: orgId,
67
+ code,
68
+ initialAmount: input.amount,
69
+ balance: input.amount,
70
+ currency: input.currency.toUpperCase(),
71
+ purchaserId: input.purchaserId,
72
+ recipientEmail: input.recipientEmail,
73
+ senderName: input.senderName,
74
+ personalMessage: input.personalMessage,
75
+ sourceOrderId: input.sourceOrderId,
76
+ expiresAt,
77
+ metadata: input.metadata ?? {},
78
+ });
79
+
80
+ // Record initial credit transaction
81
+ await this.repo.recordTransaction({
82
+ giftCardId: card.id,
83
+ type: "credit" as const,
84
+ amount: input.amount,
85
+ balanceAfter: input.amount,
86
+ note: "Initial load",
87
+ });
88
+
89
+ return Ok(card);
90
+ }
91
+
92
+ // ─── Query ───────────────────────────────────────────────────────────
93
+
94
+ async getById(orgId: string, id: string): Promise<PluginResult<GiftCard>> {
95
+ const card = await this.repo.findById(id);
96
+ if (!card) return Err("Gift card not found");
97
+ return Ok(card);
98
+ }
99
+
100
+ async getByCode(orgId: string, code: string): Promise<PluginResult<GiftCard>> {
101
+ const card = await this.repo.findByCode(normalizeCode(code));
102
+ if (!card) return Err("Gift card not found");
103
+ return Ok(card);
104
+ }
105
+
106
+ async list(orgId: string, filters?: {
107
+ status?: GiftCardStatus;
108
+ purchaserId?: string;
109
+ }): Promise<PluginResult<GiftCard[]>> {
110
+ const cards = await this.repo.list(filters);
111
+ return Ok(cards);
112
+ }
113
+
114
+ async getTransactions(
115
+ orgId: string,
116
+ giftCardId: string,
117
+ ): Promise<PluginResult<GiftCardTransaction[]>> {
118
+ const txns = await this.repo.listTransactions(giftCardId);
119
+ return Ok(txns);
120
+ }
121
+
122
+ // ─── Balance Check (Public) ──────────────────────────────────────────
123
+
124
+ async checkBalance(orgId: string, code: string): Promise<PluginResult<{
125
+ balance: number;
126
+ currency: string;
127
+ status: string;
128
+ }>> {
129
+ const card = await this.repo.findByCode(normalizeCode(code));
130
+ if (!card) return Err("Gift card not found");
131
+
132
+ return Ok({
133
+ balance: card.balance,
134
+ currency: card.currency,
135
+ status: card.status,
136
+ });
137
+ }
138
+
139
+ // ─── Debit (Concurrency-Safe) ────────────────────────────────────────
140
+
141
+ /**
142
+ * Debit a gift card balance within a transaction.
143
+ * Uses SELECT FOR UPDATE to prevent double-spend.
144
+ */
145
+ async debitWithLock(
146
+ orgId: string,
147
+ code: string,
148
+ amount: number,
149
+ orderId: string,
150
+ currency: string,
151
+ ): Promise<PluginResult<GiftCardDeduction>> {
152
+ if (amount <= 0) return Err("Debit amount must be positive");
153
+
154
+ const result = await this.transaction(async (tx) => {
155
+ const card = await this.repo.findByCodeForUpdate(normalizeCode(code), tx);
156
+ if (!card) return Err("GIFT_CARD_NOT_FOUND");
157
+ if (card.status === "disabled") return Err("GIFT_CARD_INACTIVE");
158
+ if (card.status === "exhausted") return Err("GIFT_CARD_EXHAUSTED");
159
+ if (card.expiresAt && card.expiresAt < new Date()) return Err("GIFT_CARD_EXPIRED");
160
+ if (card.currency !== currency.toUpperCase()) return Err("CURRENCY_MISMATCH");
161
+ if (card.balance < amount) return Err("INSUFFICIENT_BALANCE");
162
+
163
+ const balanceAfter = card.balance - amount;
164
+ const newStatus: GiftCardStatus = balanceAfter === 0 ? "exhausted" : "active";
165
+
166
+ await this.repo.updateBalance(card.id, balanceAfter, newStatus, card.version, tx);
167
+ await this.repo.recordTransaction(
168
+ {
169
+ giftCardId: card.id,
170
+ type: "debit" as const,
171
+ amount,
172
+ balanceAfter,
173
+ orderId,
174
+ },
175
+ { tx },
176
+ );
177
+
178
+ return Ok({
179
+ code: card.code,
180
+ giftCardId: card.id,
181
+ amount,
182
+ balanceAfter,
183
+ });
184
+ });
185
+
186
+ return result as PluginResult<GiftCardDeduction>;
187
+ }
188
+
189
+ // ─── Credit (Concurrency-Safe) ───────────────────────────────────────
190
+
191
+ /**
192
+ * Credit a gift card balance (refund/compensation).
193
+ * Uses SELECT FOR UPDATE. Cannot exceed initial_amount.
194
+ */
195
+ async creditWithLock(
196
+ orgId: string,
197
+ code: string,
198
+ amount: number,
199
+ orderId: string,
200
+ note: string,
201
+ ): Promise<PluginResult<{ balanceAfter: number }>> {
202
+ if (amount <= 0) return Err("Credit amount must be positive");
203
+
204
+ const result = await this.transaction(async (tx) => {
205
+ const card = await this.repo.findByCodeForUpdate(normalizeCode(code), tx);
206
+ if (!card) return Err("GIFT_CARD_NOT_FOUND");
207
+
208
+ // Cap credit at initial amount (prevent inflation attack)
209
+ const balanceAfter = Math.min(card.initialAmount, card.balance + amount);
210
+ const actualCredit = balanceAfter - card.balance;
211
+
212
+ if (actualCredit <= 0) {
213
+ return Ok({ balanceAfter: card.balance });
214
+ }
215
+
216
+ const newStatus: GiftCardStatus = balanceAfter > 0 ? "active" : card.status;
217
+
218
+ await this.repo.updateBalance(card.id, balanceAfter, newStatus, card.version, tx);
219
+ await this.repo.recordTransaction(
220
+ {
221
+ giftCardId: card.id,
222
+ type: "refund" as const,
223
+ amount: actualCredit,
224
+ balanceAfter,
225
+ orderId,
226
+ note,
227
+ },
228
+ { tx },
229
+ );
230
+
231
+ return Ok({ balanceAfter });
232
+ });
233
+
234
+ return result as PluginResult<{ balanceAfter: number }>;
235
+ }
236
+
237
+ // ─── Admin Operations ────────────────────────────────────────────────
238
+
239
+ async disable(orgId: string, id: string): Promise<PluginResult<GiftCard>> {
240
+ const card = await this.repo.disable(id);
241
+ if (!card) return Err("Gift card not found");
242
+ return Ok(card);
243
+ }
244
+
245
+ async adjust(
246
+ orgId: string,
247
+ id: string,
248
+ delta: number,
249
+ note: string,
250
+ ): Promise<PluginResult<GiftCard>> {
251
+ const result = await this.transaction(async (tx) => {
252
+ const card = await this.repo.findByIdForUpdate(id, tx);
253
+ if (!card) return Err("Gift card not found");
254
+
255
+ const newBalance = Math.max(0, Math.min(card.initialAmount, card.balance + delta));
256
+ const actualDelta = newBalance - card.balance;
257
+ const newStatus: GiftCardStatus = newBalance === 0 ? "exhausted" : "active";
258
+
259
+ const updated = await this.repo.updateBalance(
260
+ card.id,
261
+ newBalance,
262
+ newStatus,
263
+ card.version,
264
+ tx,
265
+ );
266
+
267
+ if (actualDelta !== 0) {
268
+ const txnType: TransactionType = actualDelta > 0 ? "credit" : "debit";
269
+ await this.repo.recordTransaction(
270
+ {
271
+ giftCardId: card.id,
272
+ type: txnType,
273
+ amount: Math.abs(actualDelta),
274
+ balanceAfter: newBalance,
275
+ note,
276
+ },
277
+ { tx },
278
+ );
279
+ }
280
+
281
+ return Ok(updated);
282
+ });
283
+
284
+ return result as PluginResult<GiftCard>;
285
+ }
286
+ }
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
2
+ import type { giftCards, giftCardTransactions } from "./schema.js";
3
+
4
+ export type GiftCard = typeof giftCards.$inferSelect;
5
+ export type GiftCardInsert = typeof giftCards.$inferInsert;
6
+ export type GiftCardTransaction = typeof giftCardTransactions.$inferSelect;
7
+ export type GiftCardTransactionInsert = typeof giftCardTransactions.$inferInsert;
8
+
9
+ export type GiftCardStatus = "active" | "disabled" | "exhausted";
10
+ export type TransactionType = "debit" | "credit" | "refund";
11
+
12
+ export interface GiftCardPluginOptions {
13
+ /** Code format pattern. Default: "XXXX-XXXX-XXXX-XXXX" */
14
+ codeFormat?: string;
15
+ /** Default expiry duration in days. null = no expiry. Default: null */
16
+ defaultExpiryDays?: number | null;
17
+ /** Maximum balance per card in minor units. Default: 10_000_00 (100,000.00) */
18
+ maxBalancePerCard?: number;
19
+ /** Email template name for gift card delivery. Default: "gift-card-delivery" */
20
+ emailTemplate?: string;
21
+ /** Allow partial redemption. Default: true */
22
+ allowPartialRedemption?: boolean;
23
+ /** Entity type that triggers gift card issuance on purchase. Default: "gift_card" */
24
+ productType?: string;
25
+ }
26
+
27
+ export const DEFAULT_OPTIONS: Required<GiftCardPluginOptions> = {
28
+ codeFormat: "XXXX-XXXX-XXXX-XXXX",
29
+ defaultExpiryDays: 0 as number,
30
+ maxBalancePerCard: 10_000_00,
31
+ emailTemplate: "gift-card-delivery",
32
+ allowPartialRedemption: true,
33
+ productType: "gift_card",
34
+ };
35
+
36
+ export interface GiftCardDeduction {
37
+ code: string;
38
+ giftCardId: string;
39
+ amount: number;
40
+ balanceAfter: number;
41
+ }