@unifiedcommerce/plugin-giftcards 0.0.1

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 (50) hide show
  1. package/dist/code-generator.d.ts +21 -0
  2. package/dist/code-generator.d.ts.map +1 -0
  3. package/dist/code-generator.js +70 -0
  4. package/dist/hooks/checkout-deduction.d.ts +12 -0
  5. package/dist/hooks/checkout-deduction.d.ts.map +1 -0
  6. package/dist/hooks/checkout-deduction.js +64 -0
  7. package/dist/hooks/checkout-issuance.d.ts +8 -0
  8. package/dist/hooks/checkout-issuance.d.ts.map +1 -0
  9. package/dist/hooks/checkout-issuance.js +58 -0
  10. package/dist/hooks/refund-credit.d.ts +7 -0
  11. package/dist/hooks/refund-credit.d.ts.map +1 -0
  12. package/dist/hooks/refund-credit.js +25 -0
  13. package/dist/index.d.ts +13 -0
  14. package/dist/index.d.ts.map +1 -0
  15. package/dist/index.js +110 -0
  16. package/dist/routes/admin.d.ts +9 -0
  17. package/dist/routes/admin.d.ts.map +1 -0
  18. package/dist/routes/admin.js +90 -0
  19. package/dist/routes/customer.d.ts +9 -0
  20. package/dist/routes/customer.d.ts.map +1 -0
  21. package/dist/routes/customer.js +23 -0
  22. package/dist/routes/public.d.ts +9 -0
  23. package/dist/routes/public.d.ts.map +1 -0
  24. package/dist/routes/public.js +21 -0
  25. package/dist/schema.d.ts +442 -0
  26. package/dist/schema.d.ts.map +1 -0
  27. package/dist/schema.js +60 -0
  28. package/dist/services/gift-card-repository.d.ts +45 -0
  29. package/dist/services/gift-card-repository.d.ts.map +1 -0
  30. package/dist/services/gift-card-repository.js +113 -0
  31. package/dist/services/gift-card-service.d.ts +45 -0
  32. package/dist/services/gift-card-service.d.ts.map +1 -0
  33. package/dist/services/gift-card-service.js +196 -0
  34. package/dist/tsconfig.tsbuildinfo +1 -0
  35. package/dist/types.d.ts +30 -0
  36. package/dist/types.d.ts.map +1 -0
  37. package/dist/types.js +8 -0
  38. package/package.json +39 -0
  39. package/src/code-generator.ts +79 -0
  40. package/src/hooks/checkout-deduction.ts +115 -0
  41. package/src/hooks/checkout-issuance.ts +93 -0
  42. package/src/hooks/refund-credit.ts +56 -0
  43. package/src/index.ts +147 -0
  44. package/src/routes/admin.ts +115 -0
  45. package/src/routes/customer.ts +30 -0
  46. package/src/routes/public.ts +31 -0
  47. package/src/schema.ts +89 -0
  48. package/src/services/gift-card-repository.ts +157 -0
  49. package/src/services/gift-card-service.ts +286 -0
  50. package/src/types.ts +41 -0
@@ -0,0 +1,30 @@
1
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
2
+ import type { giftCards, giftCardTransactions } from "./schema";
3
+ export type GiftCard = typeof giftCards.$inferSelect;
4
+ export type GiftCardInsert = typeof giftCards.$inferInsert;
5
+ export type GiftCardTransaction = typeof giftCardTransactions.$inferSelect;
6
+ export type GiftCardTransactionInsert = typeof giftCardTransactions.$inferInsert;
7
+ export type GiftCardStatus = "active" | "disabled" | "exhausted";
8
+ export type TransactionType = "debit" | "credit" | "refund";
9
+ export interface GiftCardPluginOptions {
10
+ /** Code format pattern. Default: "XXXX-XXXX-XXXX-XXXX" */
11
+ codeFormat?: string;
12
+ /** Default expiry duration in days. null = no expiry. Default: null */
13
+ defaultExpiryDays?: number | null;
14
+ /** Maximum balance per card in minor units. Default: 10_000_00 (100,000.00) */
15
+ maxBalancePerCard?: number;
16
+ /** Email template name for gift card delivery. Default: "gift-card-delivery" */
17
+ emailTemplate?: string;
18
+ /** Allow partial redemption. Default: true */
19
+ allowPartialRedemption?: boolean;
20
+ /** Entity type that triggers gift card issuance on purchase. Default: "gift_card" */
21
+ productType?: string;
22
+ }
23
+ export declare const DEFAULT_OPTIONS: Required<GiftCardPluginOptions>;
24
+ export interface GiftCardDeduction {
25
+ code: string;
26
+ giftCardId: string;
27
+ amount: number;
28
+ balanceAfter: number;
29
+ }
30
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,uBAAuB,CAAC;AAC5D,OAAO,KAAK,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAEhE,MAAM,MAAM,QAAQ,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AACrD,MAAM,MAAM,cAAc,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAC3D,MAAM,MAAM,mBAAmB,GAAG,OAAO,oBAAoB,CAAC,YAAY,CAAC;AAC3E,MAAM,MAAM,yBAAyB,GAAG,OAAO,oBAAoB,CAAC,YAAY,CAAC;AAEjF,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAC;AACjE,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,+EAA+E;IAC/E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gFAAgF;IAChF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8CAA8C;IAC9C,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,qBAAqB,CAO3D,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;CACtB"}
package/dist/types.js ADDED
@@ -0,0 +1,8 @@
1
+ export const DEFAULT_OPTIONS = {
2
+ codeFormat: "XXXX-XXXX-XXXX-XXXX",
3
+ defaultExpiryDays: null,
4
+ maxBalancePerCard: 10_000_00,
5
+ emailTemplate: "gift-card-delivery",
6
+ allowPartialRedemption: true,
7
+ productType: "gift_card",
8
+ };
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@unifiedcommerce/plugin-giftcards",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "check-types": "tsc --noEmit",
14
+ "lint": "eslint . --max-warnings 1000",
15
+ "test": "vitest run"
16
+ },
17
+ "dependencies": {
18
+ "@hono/zod-openapi": "^1.2.2",
19
+ "@unifiedcommerce/core": "*",
20
+ "drizzle-orm": "^0.45.1",
21
+ "hono": "^4.12.5"
22
+ },
23
+ "devDependencies": {
24
+ "@repo/eslint-config": "*",
25
+ "@repo/typescript-config": "*",
26
+ "@types/node": "^24.5.2",
27
+ "eslint": "^9.39.1",
28
+ "typescript": "5.9.2",
29
+ "vitest": "^3.2.4"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "files": [
35
+ "dist",
36
+ "src",
37
+ "README.md"
38
+ ]
39
+ }
@@ -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";
4
+ import type { GiftCardDeduction } from "../types";
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";
4
+ import type { GiftCardPluginOptions } from "../types";
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";
4
+ import type { GiftCardDeduction } from "../types";
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,147 @@
1
+ import { defineCommercePlugin } from "@unifiedcommerce/core";
2
+ import { giftCards, giftCardTransactions } from "./schema";
3
+ import { GiftCardService } from "./services/gift-card-service";
4
+ import {
5
+ buildCheckoutDeductionHook,
6
+ buildCheckoutCompensationHook,
7
+ } from "./hooks/checkout-deduction";
8
+ import { buildGiftCardIssuanceHook } from "./hooks/checkout-issuance";
9
+ import { buildRefundCreditHook } from "./hooks/refund-credit";
10
+ import { buildAdminRoutes } from "./routes/admin";
11
+ import { buildPublicRoutes } from "./routes/public";
12
+ import { buildCustomerRoutes } from "./routes/customer";
13
+ import type { GiftCardPluginOptions, Db } from "./types";
14
+ import { DEFAULT_OPTIONS } from "./types";
15
+
16
+ export type { GiftCardPluginOptions } from "./types";
17
+ export { GiftCardService } from "./services/gift-card-service";
18
+
19
+ export function giftCardPlugin(userOptions: GiftCardPluginOptions = {}) {
20
+ const options: Required<GiftCardPluginOptions> = {
21
+ ...DEFAULT_OPTIONS,
22
+ ...userOptions,
23
+ // Preserve null for defaultExpiryDays when user doesn't set it
24
+ defaultExpiryDays: userOptions.defaultExpiryDays ?? DEFAULT_OPTIONS.defaultExpiryDays,
25
+ };
26
+
27
+ return defineCommercePlugin({
28
+ id: "gift-cards",
29
+ version: "1.0.0",
30
+
31
+ permissions: [
32
+ {
33
+ scope: "gift-cards:admin",
34
+ description:
35
+ "Create, list, disable, and adjust gift cards. Required for all admin routes.",
36
+ },
37
+ ],
38
+
39
+ schema: () => ({
40
+ giftCards,
41
+ giftCardTransactions,
42
+ }),
43
+
44
+ hooks: () => {
45
+ // Hooks are registered before the service is available (no DB context).
46
+ // They will be populated with the real service in routes() where ctx is available.
47
+ // For now, return empty — we'll wire them up via a shared reference.
48
+ return [];
49
+ },
50
+
51
+ routes: (ctx) => {
52
+ const db = ctx.database.db as unknown as Db;
53
+ if (!db) return [];
54
+
55
+ const service = new GiftCardService(
56
+ db,
57
+ ctx.database.transaction as unknown as (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>,
58
+ options,
59
+ );
60
+
61
+ return [
62
+ ...buildAdminRoutes(service, ctx),
63
+ ...buildPublicRoutes(service, ctx),
64
+ ...buildCustomerRoutes(service, ctx),
65
+ ];
66
+ },
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Standalone factory for the plugin with hooks wired.
72
+ *
73
+ * Since hooks() runs before routes() (no DB context), we use a deferred
74
+ * service pattern: hooks capture a shared reference that gets populated
75
+ * when routes() runs with the real DB.
76
+ */
77
+ export function giftCardPluginWithHooks(
78
+ userOptions: GiftCardPluginOptions = {},
79
+ ) {
80
+ const options: Required<GiftCardPluginOptions> = {
81
+ ...DEFAULT_OPTIONS,
82
+ ...userOptions,
83
+ defaultExpiryDays: userOptions.defaultExpiryDays ?? DEFAULT_OPTIONS.defaultExpiryDays,
84
+ };
85
+
86
+ // Shared mutable reference — populated when routes() runs
87
+ const serviceRef: { current: GiftCardService | null } = { current: null };
88
+
89
+ return defineCommercePlugin({
90
+ id: "gift-cards",
91
+ version: "1.0.0",
92
+
93
+ permissions: [
94
+ {
95
+ scope: "gift-cards:admin",
96
+ description:
97
+ "Create, list, disable, and adjust gift cards. Required for all admin routes.",
98
+ },
99
+ ],
100
+
101
+ schema: () => ({
102
+ giftCards,
103
+ giftCardTransactions,
104
+ }),
105
+
106
+ hooks: () => {
107
+ // Create a lazy proxy service that defers to serviceRef.current
108
+ const lazyService = new Proxy({} as GiftCardService, {
109
+ get(_target, prop) {
110
+ if (!serviceRef.current) {
111
+ throw new Error(
112
+ "Gift card service not initialized — hooks ran before routes()",
113
+ );
114
+ }
115
+ return (serviceRef.current as unknown as Record<string, unknown>)[prop as string];
116
+ },
117
+ });
118
+
119
+ return [
120
+ buildCheckoutDeductionHook(lazyService),
121
+ buildCheckoutCompensationHook(lazyService),
122
+ buildGiftCardIssuanceHook(lazyService, options),
123
+ buildRefundCreditHook(lazyService),
124
+ ];
125
+ },
126
+
127
+ routes: (ctx) => {
128
+ const db = ctx.database.db as unknown as Db;
129
+ if (!db) return [];
130
+
131
+ const service = new GiftCardService(
132
+ db,
133
+ ctx.database.transaction as unknown as (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>,
134
+ options,
135
+ );
136
+
137
+ // Wire up the shared reference so hooks can access the service
138
+ serviceRef.current = service;
139
+
140
+ return [
141
+ ...buildAdminRoutes(service, ctx),
142
+ ...buildPublicRoutes(service, ctx),
143
+ ...buildCustomerRoutes(service, ctx),
144
+ ];
145
+ },
146
+ });
147
+ }
@@ -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";
4
+ import type { PluginRouteRegistration } from "@unifiedcommerce/core";
5
+ import { formatCode } from "../code-generator";
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
+ }