@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.
- package/dist/code-generator.d.ts +21 -0
- package/dist/code-generator.d.ts.map +1 -0
- package/dist/code-generator.js +70 -0
- package/dist/hooks/checkout-deduction.d.ts +12 -0
- package/dist/hooks/checkout-deduction.d.ts.map +1 -0
- package/dist/hooks/checkout-deduction.js +64 -0
- package/dist/hooks/checkout-issuance.d.ts +8 -0
- package/dist/hooks/checkout-issuance.d.ts.map +1 -0
- package/dist/hooks/checkout-issuance.js +58 -0
- package/dist/hooks/refund-credit.d.ts +7 -0
- package/dist/hooks/refund-credit.d.ts.map +1 -0
- package/dist/hooks/refund-credit.js +25 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +110 -0
- package/dist/routes/admin.d.ts +9 -0
- package/dist/routes/admin.d.ts.map +1 -0
- package/dist/routes/admin.js +90 -0
- package/dist/routes/customer.d.ts +9 -0
- package/dist/routes/customer.d.ts.map +1 -0
- package/dist/routes/customer.js +23 -0
- package/dist/routes/public.d.ts +9 -0
- package/dist/routes/public.d.ts.map +1 -0
- package/dist/routes/public.js +21 -0
- package/dist/schema.d.ts +442 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +60 -0
- package/dist/services/gift-card-repository.d.ts +45 -0
- package/dist/services/gift-card-repository.d.ts.map +1 -0
- package/dist/services/gift-card-repository.js +113 -0
- package/dist/services/gift-card-service.d.ts +45 -0
- package/dist/services/gift-card-service.d.ts.map +1 -0
- package/dist/services/gift-card-service.js +196 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/package.json +39 -0
- package/src/code-generator.ts +79 -0
- package/src/hooks/checkout-deduction.ts +115 -0
- package/src/hooks/checkout-issuance.ts +93 -0
- package/src/hooks/refund-credit.ts +56 -0
- package/src/index.ts +147 -0
- package/src/routes/admin.ts +115 -0
- package/src/routes/customer.ts +30 -0
- package/src/routes/public.ts +31 -0
- package/src/schema.ts +89 -0
- package/src/services/gift-card-repository.ts +157 -0
- package/src/services/gift-card-service.ts +286 -0
- package/src/types.ts +41 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Generate a cryptographically secure gift card code.
|
|
3
|
+
*
|
|
4
|
+
* Format: XXXX-XXXX-XXXX-XXXX (16 alphanumeric chars)
|
|
5
|
+
* Uses crypto.randomBytes for uniform distribution.
|
|
6
|
+
*
|
|
7
|
+
* @param format - Pattern where X is replaced with a random char. Default: "XXXX-XXXX-XXXX-XXXX"
|
|
8
|
+
*/
|
|
9
|
+
export declare function generateGiftCardCode(format?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Normalize a gift card code for database lookup.
|
|
12
|
+
* Strips hyphens/spaces and uppercases.
|
|
13
|
+
*/
|
|
14
|
+
export declare function normalizeCode(code: string): string;
|
|
15
|
+
/**
|
|
16
|
+
* Format a raw code string into the display format.
|
|
17
|
+
*/
|
|
18
|
+
export declare function formatCode(raw: string, format?: string): string;
|
|
19
|
+
/** The character set used for code generation (for validation/testing) */
|
|
20
|
+
export declare const CODE_CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
21
|
+
//# sourceMappingURL=code-generator.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"code-generator.d.ts","sourceRoot":"","sources":["../src/code-generator.ts"],"names":[],"mappings":"AASA;;;;;;;GAOG;AACH,wBAAgB,oBAAoB,CAAC,MAAM,SAAwB,GAAG,MAAM,CA+B3E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,SAAwB,GAAG,MAAM,CAc9E;AAED,0EAA0E;AAC1E,eAAO,MAAM,YAAY,oCAAU,CAAC"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
/**
|
|
3
|
+
* Allowed characters for gift card codes.
|
|
4
|
+
* Excludes visually ambiguous characters: 0, O, 1, I, L
|
|
5
|
+
* Charset size: 30 → 30^16 ≈ 7.2 × 10^23 possible codes
|
|
6
|
+
*/
|
|
7
|
+
const CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
8
|
+
/**
|
|
9
|
+
* Generate a cryptographically secure gift card code.
|
|
10
|
+
*
|
|
11
|
+
* Format: XXXX-XXXX-XXXX-XXXX (16 alphanumeric chars)
|
|
12
|
+
* Uses crypto.randomBytes for uniform distribution.
|
|
13
|
+
*
|
|
14
|
+
* @param format - Pattern where X is replaced with a random char. Default: "XXXX-XXXX-XXXX-XXXX"
|
|
15
|
+
*/
|
|
16
|
+
export function generateGiftCardCode(format = "XXXX-XXXX-XXXX-XXXX") {
|
|
17
|
+
const charCount = (format.match(/X/g) ?? []).length;
|
|
18
|
+
// Request extra bytes to handle modulo bias rejection
|
|
19
|
+
const bytes = randomBytes(charCount * 2);
|
|
20
|
+
let byteIdx = 0;
|
|
21
|
+
let result = "";
|
|
22
|
+
for (const ch of format) {
|
|
23
|
+
if (ch === "X") {
|
|
24
|
+
// Rejection sampling to avoid modulo bias
|
|
25
|
+
// CHARSET.length = 30, so we reject values >= 240 (240 = 30 * 8)
|
|
26
|
+
let value;
|
|
27
|
+
do {
|
|
28
|
+
if (byteIdx >= bytes.length) {
|
|
29
|
+
// Extremely unlikely — generate more bytes
|
|
30
|
+
const extra = randomBytes(charCount);
|
|
31
|
+
for (let i = 0; i < extra.length; i++) {
|
|
32
|
+
bytes[byteIdx + i] = extra[i];
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
value = bytes[byteIdx++];
|
|
36
|
+
} while (value >= 240); // 240 = 30 * 8, ensures uniform distribution
|
|
37
|
+
result += CHARSET[value % CHARSET.length];
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
result += ch;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return result;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Normalize a gift card code for database lookup.
|
|
47
|
+
* Strips hyphens/spaces and uppercases.
|
|
48
|
+
*/
|
|
49
|
+
export function normalizeCode(code) {
|
|
50
|
+
return code.replace(/[-\s]/g, "").toUpperCase();
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format a raw code string into the display format.
|
|
54
|
+
*/
|
|
55
|
+
export function formatCode(raw, format = "XXXX-XXXX-XXXX-XXXX") {
|
|
56
|
+
const chars = raw.replace(/[-\s]/g, "").toUpperCase();
|
|
57
|
+
let charIdx = 0;
|
|
58
|
+
let result = "";
|
|
59
|
+
for (const ch of format) {
|
|
60
|
+
if (ch === "X" && charIdx < chars.length) {
|
|
61
|
+
result += chars[charIdx++];
|
|
62
|
+
}
|
|
63
|
+
else if (ch !== "X") {
|
|
64
|
+
result += ch;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
/** The character set used for code generation (for validation/testing) */
|
|
70
|
+
export const CODE_CHARSET = CHARSET;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { PluginHookRegistration } from "@unifiedcommerce/core";
|
|
2
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
3
|
+
/**
|
|
4
|
+
* checkout.beforePayment hook — deducts gift card balances before the
|
|
5
|
+
* payment adapter authorizes the remaining amount.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildCheckoutDeductionHook(service: GiftCardService): PluginHookRegistration;
|
|
8
|
+
/**
|
|
9
|
+
* checkout.afterCreate hook — compensates gift card deductions if checkout fails.
|
|
10
|
+
*/
|
|
11
|
+
export declare function buildCheckoutCompensationHook(service: GiftCardService): PluginHookRegistration;
|
|
12
|
+
//# sourceMappingURL=checkout-deduction.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkout-deduction.d.ts","sourceRoot":"","sources":["../../src/hooks/checkout-deduction.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAwBrE;;;GAGG;AACH,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,eAAe,GACvB,sBAAsB,CAkDxB;AAED;;GAEG;AACH,wBAAgB,6BAA6B,CAC3C,OAAO,EAAE,eAAe,GACvB,sBAAsB,CAyBxB"}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { resolveOrgId } from "@unifiedcommerce/core";
|
|
2
|
+
/**
|
|
3
|
+
* checkout.beforePayment hook — deducts gift card balances before the
|
|
4
|
+
* payment adapter authorizes the remaining amount.
|
|
5
|
+
*/
|
|
6
|
+
export function buildCheckoutDeductionHook(service) {
|
|
7
|
+
const handler = async (args) => {
|
|
8
|
+
const { data, context } = args;
|
|
9
|
+
const orgId = resolveOrgId(context.actor);
|
|
10
|
+
const codes = data.metadata?.giftCardCodes;
|
|
11
|
+
if (!codes?.length)
|
|
12
|
+
return data;
|
|
13
|
+
let remaining = data.total;
|
|
14
|
+
const deductions = [];
|
|
15
|
+
for (const code of codes) {
|
|
16
|
+
if (remaining <= 0)
|
|
17
|
+
break;
|
|
18
|
+
const balanceResult = await service.checkBalance(orgId, code);
|
|
19
|
+
if (!balanceResult.ok)
|
|
20
|
+
continue;
|
|
21
|
+
const deductAmount = Math.min(remaining, balanceResult.value.balance);
|
|
22
|
+
if (deductAmount <= 0)
|
|
23
|
+
continue;
|
|
24
|
+
const result = await service.debitWithLock(orgId, code, deductAmount, data.checkoutId, data.currency);
|
|
25
|
+
if (result.ok) {
|
|
26
|
+
deductions.push(result.value);
|
|
27
|
+
remaining -= deductAmount;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const giftCardTotal = deductions.reduce((sum, d) => sum + d.amount, 0);
|
|
31
|
+
return {
|
|
32
|
+
...data,
|
|
33
|
+
total: Math.max(0, remaining),
|
|
34
|
+
metadata: {
|
|
35
|
+
...data.metadata,
|
|
36
|
+
giftCardDeductions: deductions,
|
|
37
|
+
giftCardTotal,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
};
|
|
41
|
+
return {
|
|
42
|
+
key: "checkout.beforePayment",
|
|
43
|
+
handler: handler,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* checkout.afterCreate hook — compensates gift card deductions if checkout fails.
|
|
48
|
+
*/
|
|
49
|
+
export function buildCheckoutCompensationHook(service) {
|
|
50
|
+
const handler = async (args) => {
|
|
51
|
+
const { data, result, context } = args;
|
|
52
|
+
const orgId = resolveOrgId(context.actor);
|
|
53
|
+
if (!result) {
|
|
54
|
+
const deductions = data.metadata?.giftCardDeductions;
|
|
55
|
+
for (const d of deductions ?? []) {
|
|
56
|
+
await service.creditWithLock(orgId, d.code, d.amount, data.checkoutId, "Checkout failed — balance restored");
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
};
|
|
60
|
+
return {
|
|
61
|
+
key: "checkout.afterCreate",
|
|
62
|
+
handler: handler,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { PluginHookRegistration } from "@unifiedcommerce/core";
|
|
2
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
3
|
+
import type { GiftCardPluginOptions } from "../types";
|
|
4
|
+
/**
|
|
5
|
+
* checkout.afterCreate hook — issues gift cards when a gift card product is purchased.
|
|
6
|
+
*/
|
|
7
|
+
export declare function buildGiftCardIssuanceHook(service: GiftCardService, options: Required<GiftCardPluginOptions>, enqueueJob?: (slug: string, input: Record<string, unknown>) => Promise<string>): PluginHookRegistration;
|
|
8
|
+
//# sourceMappingURL=checkout-issuance.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"checkout-issuance.d.ts","sourceRoot":"","sources":["../../src/hooks/checkout-issuance.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AA6BtD;;GAEG;AACH,wBAAgB,yBAAyB,CACvC,OAAO,EAAE,eAAe,EACxB,OAAO,EAAE,QAAQ,CAAC,qBAAqB,CAAC,EACxC,UAAU,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAAK,OAAO,CAAC,MAAM,CAAC,GAC7E,sBAAsB,CAqDxB"}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { resolveOrgId } from "@unifiedcommerce/core";
|
|
2
|
+
/**
|
|
3
|
+
* checkout.afterCreate hook — issues gift cards when a gift card product is purchased.
|
|
4
|
+
*/
|
|
5
|
+
export function buildGiftCardIssuanceHook(service, options, enqueueJob) {
|
|
6
|
+
const handler = async (args) => {
|
|
7
|
+
const { result, context } = args;
|
|
8
|
+
if (!result?.lineItems)
|
|
9
|
+
return;
|
|
10
|
+
const orgId = resolveOrgId(context.actor);
|
|
11
|
+
for (const item of result.lineItems) {
|
|
12
|
+
if (item.entityType !== options.productType)
|
|
13
|
+
continue;
|
|
14
|
+
const amount = item.totalPrice ?? (item.unitPrice ?? 0) * item.quantity;
|
|
15
|
+
if (amount <= 0)
|
|
16
|
+
continue;
|
|
17
|
+
const orderMeta = result.metadata;
|
|
18
|
+
const recipientEmail = orderMeta?.giftCardRecipientEmail ?? undefined;
|
|
19
|
+
const senderName = orderMeta?.giftCardSenderName ?? undefined;
|
|
20
|
+
const personalMessage = orderMeta?.giftCardPersonalMessage ?? undefined;
|
|
21
|
+
const createInput = {
|
|
22
|
+
amount,
|
|
23
|
+
currency: result.currency,
|
|
24
|
+
sourceOrderId: result.id,
|
|
25
|
+
};
|
|
26
|
+
if (result.customerId)
|
|
27
|
+
createInput.purchaserId = result.customerId;
|
|
28
|
+
if (recipientEmail)
|
|
29
|
+
createInput.recipientEmail = recipientEmail;
|
|
30
|
+
if (senderName)
|
|
31
|
+
createInput.senderName = senderName;
|
|
32
|
+
if (personalMessage)
|
|
33
|
+
createInput.personalMessage = personalMessage;
|
|
34
|
+
const cardResult = await service.create(orgId, createInput);
|
|
35
|
+
if (cardResult.ok && enqueueJob && recipientEmail) {
|
|
36
|
+
try {
|
|
37
|
+
await enqueueJob("gift-card.deliver", {
|
|
38
|
+
giftCardId: cardResult.value.id,
|
|
39
|
+
code: cardResult.value.code,
|
|
40
|
+
amount,
|
|
41
|
+
currency: result.currency,
|
|
42
|
+
recipientEmail,
|
|
43
|
+
senderName: senderName ?? "",
|
|
44
|
+
personalMessage: personalMessage ?? "",
|
|
45
|
+
template: options.emailTemplate,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
// Email delivery failure should not break checkout
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
return {
|
|
55
|
+
key: "checkout.afterCreate",
|
|
56
|
+
handler: handler,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { PluginHookRegistration } from "@unifiedcommerce/core";
|
|
2
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
3
|
+
/**
|
|
4
|
+
* order.afterUpdate hook — restores gift card balances when an order is refunded.
|
|
5
|
+
*/
|
|
6
|
+
export declare function buildRefundCreditHook(service: GiftCardService): PluginHookRegistration;
|
|
7
|
+
//# sourceMappingURL=refund-credit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"refund-credit.d.ts","sourceRoot":"","sources":["../../src/hooks/refund-credit.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AAkBrE;;GAEG;AACH,wBAAgB,qBAAqB,CACnC,OAAO,EAAE,eAAe,GACvB,sBAAsB,CA8BxB"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { resolveOrgId } from "@unifiedcommerce/core";
|
|
2
|
+
/**
|
|
3
|
+
* order.afterUpdate hook — restores gift card balances when an order is refunded.
|
|
4
|
+
*/
|
|
5
|
+
export function buildRefundCreditHook(service) {
|
|
6
|
+
const handler = async (args) => {
|
|
7
|
+
const { result, context } = args;
|
|
8
|
+
if (!result)
|
|
9
|
+
return;
|
|
10
|
+
const status = result.status;
|
|
11
|
+
if (status !== "refunded" && status !== "cancelled")
|
|
12
|
+
return;
|
|
13
|
+
const deductions = result.metadata?.giftCardDeductions;
|
|
14
|
+
if (!deductions?.length)
|
|
15
|
+
return;
|
|
16
|
+
const orgId = resolveOrgId(context.actor);
|
|
17
|
+
for (const d of deductions) {
|
|
18
|
+
await service.creditWithLock(orgId, d.code, d.amount, result.id, `Order ${status} — balance restored`);
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return {
|
|
22
|
+
key: "order.afterUpdate",
|
|
23
|
+
handler: handler,
|
|
24
|
+
};
|
|
25
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { GiftCardPluginOptions } from "./types";
|
|
2
|
+
export type { GiftCardPluginOptions } from "./types";
|
|
3
|
+
export { GiftCardService } from "./services/gift-card-service";
|
|
4
|
+
export declare function giftCardPlugin(userOptions?: GiftCardPluginOptions): import("@unifiedcommerce/core").CommercePlugin;
|
|
5
|
+
/**
|
|
6
|
+
* Standalone factory for the plugin with hooks wired.
|
|
7
|
+
*
|
|
8
|
+
* Since hooks() runs before routes() (no DB context), we use a deferred
|
|
9
|
+
* service pattern: hooks capture a shared reference that gets populated
|
|
10
|
+
* when routes() runs with the real DB.
|
|
11
|
+
*/
|
|
12
|
+
export declare function giftCardPluginWithHooks(userOptions?: GiftCardPluginOptions): import("@unifiedcommerce/core").CommercePlugin;
|
|
13
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAYA,OAAO,KAAK,EAAE,qBAAqB,EAAM,MAAM,SAAS,CAAC;AAGzD,YAAY,EAAE,qBAAqB,EAAE,MAAM,SAAS,CAAC;AACrD,OAAO,EAAE,eAAe,EAAE,MAAM,8BAA8B,CAAC;AAE/D,wBAAgB,cAAc,CAAC,WAAW,GAAE,qBAA0B,kDAiDrE;AAED;;;;;;GAMG;AACH,wBAAgB,uBAAuB,CACrC,WAAW,GAAE,qBAA0B,kDAqExC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@unifiedcommerce/core";
|
|
2
|
+
import { giftCards, giftCardTransactions } from "./schema";
|
|
3
|
+
import { GiftCardService } from "./services/gift-card-service";
|
|
4
|
+
import { buildCheckoutDeductionHook, buildCheckoutCompensationHook, } from "./hooks/checkout-deduction";
|
|
5
|
+
import { buildGiftCardIssuanceHook } from "./hooks/checkout-issuance";
|
|
6
|
+
import { buildRefundCreditHook } from "./hooks/refund-credit";
|
|
7
|
+
import { buildAdminRoutes } from "./routes/admin";
|
|
8
|
+
import { buildPublicRoutes } from "./routes/public";
|
|
9
|
+
import { buildCustomerRoutes } from "./routes/customer";
|
|
10
|
+
import { DEFAULT_OPTIONS } from "./types";
|
|
11
|
+
export { GiftCardService } from "./services/gift-card-service";
|
|
12
|
+
export function giftCardPlugin(userOptions = {}) {
|
|
13
|
+
const options = {
|
|
14
|
+
...DEFAULT_OPTIONS,
|
|
15
|
+
...userOptions,
|
|
16
|
+
// Preserve null for defaultExpiryDays when user doesn't set it
|
|
17
|
+
defaultExpiryDays: userOptions.defaultExpiryDays ?? DEFAULT_OPTIONS.defaultExpiryDays,
|
|
18
|
+
};
|
|
19
|
+
return defineCommercePlugin({
|
|
20
|
+
id: "gift-cards",
|
|
21
|
+
version: "1.0.0",
|
|
22
|
+
permissions: [
|
|
23
|
+
{
|
|
24
|
+
scope: "gift-cards:admin",
|
|
25
|
+
description: "Create, list, disable, and adjust gift cards. Required for all admin routes.",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
schema: () => ({
|
|
29
|
+
giftCards,
|
|
30
|
+
giftCardTransactions,
|
|
31
|
+
}),
|
|
32
|
+
hooks: () => {
|
|
33
|
+
// Hooks are registered before the service is available (no DB context).
|
|
34
|
+
// They will be populated with the real service in routes() where ctx is available.
|
|
35
|
+
// For now, return empty — we'll wire them up via a shared reference.
|
|
36
|
+
return [];
|
|
37
|
+
},
|
|
38
|
+
routes: (ctx) => {
|
|
39
|
+
const db = ctx.database.db;
|
|
40
|
+
if (!db)
|
|
41
|
+
return [];
|
|
42
|
+
const service = new GiftCardService(db, ctx.database.transaction, options);
|
|
43
|
+
return [
|
|
44
|
+
...buildAdminRoutes(service, ctx),
|
|
45
|
+
...buildPublicRoutes(service, ctx),
|
|
46
|
+
...buildCustomerRoutes(service, ctx),
|
|
47
|
+
];
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Standalone factory for the plugin with hooks wired.
|
|
53
|
+
*
|
|
54
|
+
* Since hooks() runs before routes() (no DB context), we use a deferred
|
|
55
|
+
* service pattern: hooks capture a shared reference that gets populated
|
|
56
|
+
* when routes() runs with the real DB.
|
|
57
|
+
*/
|
|
58
|
+
export function giftCardPluginWithHooks(userOptions = {}) {
|
|
59
|
+
const options = {
|
|
60
|
+
...DEFAULT_OPTIONS,
|
|
61
|
+
...userOptions,
|
|
62
|
+
defaultExpiryDays: userOptions.defaultExpiryDays ?? DEFAULT_OPTIONS.defaultExpiryDays,
|
|
63
|
+
};
|
|
64
|
+
// Shared mutable reference — populated when routes() runs
|
|
65
|
+
const serviceRef = { current: null };
|
|
66
|
+
return defineCommercePlugin({
|
|
67
|
+
id: "gift-cards",
|
|
68
|
+
version: "1.0.0",
|
|
69
|
+
permissions: [
|
|
70
|
+
{
|
|
71
|
+
scope: "gift-cards:admin",
|
|
72
|
+
description: "Create, list, disable, and adjust gift cards. Required for all admin routes.",
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
schema: () => ({
|
|
76
|
+
giftCards,
|
|
77
|
+
giftCardTransactions,
|
|
78
|
+
}),
|
|
79
|
+
hooks: () => {
|
|
80
|
+
// Create a lazy proxy service that defers to serviceRef.current
|
|
81
|
+
const lazyService = new Proxy({}, {
|
|
82
|
+
get(_target, prop) {
|
|
83
|
+
if (!serviceRef.current) {
|
|
84
|
+
throw new Error("Gift card service not initialized — hooks ran before routes()");
|
|
85
|
+
}
|
|
86
|
+
return serviceRef.current[prop];
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
return [
|
|
90
|
+
buildCheckoutDeductionHook(lazyService),
|
|
91
|
+
buildCheckoutCompensationHook(lazyService),
|
|
92
|
+
buildGiftCardIssuanceHook(lazyService, options),
|
|
93
|
+
buildRefundCreditHook(lazyService),
|
|
94
|
+
];
|
|
95
|
+
},
|
|
96
|
+
routes: (ctx) => {
|
|
97
|
+
const db = ctx.database.db;
|
|
98
|
+
if (!db)
|
|
99
|
+
return [];
|
|
100
|
+
const service = new GiftCardService(db, ctx.database.transaction, options);
|
|
101
|
+
// Wire up the shared reference so hooks can access the service
|
|
102
|
+
serviceRef.current = service;
|
|
103
|
+
return [
|
|
104
|
+
...buildAdminRoutes(service, ctx),
|
|
105
|
+
...buildPublicRoutes(service, ctx),
|
|
106
|
+
...buildCustomerRoutes(service, ctx),
|
|
107
|
+
];
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
2
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
3
|
+
export declare function buildAdminRoutes(service: GiftCardService, ctx: {
|
|
4
|
+
services?: Record<string, unknown>;
|
|
5
|
+
database?: {
|
|
6
|
+
db: unknown;
|
|
7
|
+
};
|
|
8
|
+
}): PluginRouteRegistration[];
|
|
9
|
+
//# sourceMappingURL=admin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"admin.d.ts","sourceRoot":"","sources":["../../src/routes/admin.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAGrE,wBAAgB,gBAAgB,CAC9B,OAAO,EAAE,eAAe,EACxB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CAyG3B"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import { formatCode } from "../code-generator";
|
|
4
|
+
export function buildAdminRoutes(service, ctx) {
|
|
5
|
+
const r = router("Gift Cards (Admin)", "/gift-cards", ctx);
|
|
6
|
+
// ─── Create Gift Card ─────────────────────────────────────────────
|
|
7
|
+
r.post("/")
|
|
8
|
+
.summary("Create gift card")
|
|
9
|
+
.permission("gift-cards:admin")
|
|
10
|
+
.input(z.object({
|
|
11
|
+
amount: z.number().int().positive().describe("Amount in minor units (cents)"),
|
|
12
|
+
currency: z.string().min(3).max(3).describe("ISO 4217 currency code"),
|
|
13
|
+
recipientEmail: z.string().email().optional(),
|
|
14
|
+
senderName: z.string().optional(),
|
|
15
|
+
personalMessage: z.string().max(500).optional(),
|
|
16
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
17
|
+
}))
|
|
18
|
+
.handler(async ({ input, orgId }) => {
|
|
19
|
+
const body = input;
|
|
20
|
+
const result = await service.create(orgId, body);
|
|
21
|
+
if (!result.ok)
|
|
22
|
+
throw new Error(result.error);
|
|
23
|
+
return {
|
|
24
|
+
...result.value,
|
|
25
|
+
displayCode: formatCode(result.value.code),
|
|
26
|
+
};
|
|
27
|
+
});
|
|
28
|
+
// ─── List Gift Cards ──────────────────────────────────────────────
|
|
29
|
+
r.get("/")
|
|
30
|
+
.summary("List gift cards")
|
|
31
|
+
.permission("gift-cards:admin")
|
|
32
|
+
.query(z.object({
|
|
33
|
+
status: z.enum(["active", "disabled", "exhausted"]).optional(),
|
|
34
|
+
purchaserId: z.string().optional(),
|
|
35
|
+
}))
|
|
36
|
+
.handler(async ({ query, orgId }) => {
|
|
37
|
+
const q = query;
|
|
38
|
+
const filters = {};
|
|
39
|
+
if (q.status === "active" || q.status === "disabled" || q.status === "exhausted") {
|
|
40
|
+
filters.status = q.status;
|
|
41
|
+
}
|
|
42
|
+
if (q.purchaserId)
|
|
43
|
+
filters.purchaserId = q.purchaserId;
|
|
44
|
+
const result = await service.list(orgId, filters);
|
|
45
|
+
if (!result.ok)
|
|
46
|
+
throw new Error("Failed to list gift cards");
|
|
47
|
+
return result.value;
|
|
48
|
+
});
|
|
49
|
+
// ─── Get Gift Card by ID ──────────────────────────────────────────
|
|
50
|
+
r.get("/{id}")
|
|
51
|
+
.summary("Get gift card details")
|
|
52
|
+
.permission("gift-cards:admin")
|
|
53
|
+
.handler(async ({ params, orgId }) => {
|
|
54
|
+
const cardResult = await service.getById(orgId, params.id);
|
|
55
|
+
if (!cardResult.ok)
|
|
56
|
+
throw new Error(cardResult.error);
|
|
57
|
+
const txnResult = await service.getTransactions(orgId, cardResult.value.id);
|
|
58
|
+
return {
|
|
59
|
+
...cardResult.value,
|
|
60
|
+
displayCode: formatCode(cardResult.value.code),
|
|
61
|
+
transactions: txnResult.ok ? txnResult.value : [],
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
// ─── Disable Gift Card ────────────────────────────────────────────
|
|
65
|
+
r.post("/{id}/disable")
|
|
66
|
+
.summary("Disable gift card")
|
|
67
|
+
.permission("gift-cards:admin")
|
|
68
|
+
.handler(async ({ params, orgId }) => {
|
|
69
|
+
const result = await service.disable(orgId, params.id);
|
|
70
|
+
if (!result.ok)
|
|
71
|
+
throw new Error(result.error);
|
|
72
|
+
return result.value;
|
|
73
|
+
});
|
|
74
|
+
// ─── Manual Balance Adjustment ────────────────────────────────────
|
|
75
|
+
r.post("/{id}/adjust")
|
|
76
|
+
.summary("Adjust gift card balance")
|
|
77
|
+
.permission("gift-cards:admin")
|
|
78
|
+
.input(z.object({
|
|
79
|
+
delta: z.number().int().describe("Adjustment amount in minor units (positive=credit, negative=debit)"),
|
|
80
|
+
note: z.string().min(1).max(500).describe("Reason for adjustment"),
|
|
81
|
+
}))
|
|
82
|
+
.handler(async ({ params, input, orgId }) => {
|
|
83
|
+
const body = input;
|
|
84
|
+
const result = await service.adjust(orgId, params.id, body.delta, body.note);
|
|
85
|
+
if (!result.ok)
|
|
86
|
+
throw new Error(result.error);
|
|
87
|
+
return result.value;
|
|
88
|
+
});
|
|
89
|
+
return r.routes();
|
|
90
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
2
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
3
|
+
export declare function buildCustomerRoutes(service: GiftCardService, ctx: {
|
|
4
|
+
services?: Record<string, unknown>;
|
|
5
|
+
database?: {
|
|
6
|
+
db: unknown;
|
|
7
|
+
};
|
|
8
|
+
}): PluginRouteRegistration[];
|
|
9
|
+
//# sourceMappingURL=customer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"customer.d.ts","sourceRoot":"","sources":["../../src/routes/customer.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAGrE,wBAAgB,mBAAmB,CACjC,OAAO,EAAE,eAAe,EACxB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CAqB3B"}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { formatCode } from "../code-generator";
|
|
3
|
+
export function buildCustomerRoutes(service, ctx) {
|
|
4
|
+
const r = router("Gift Cards (Customer)", "/me/gift-cards", ctx);
|
|
5
|
+
// ─── List Customer's Gift Cards ───────────────────────────────────
|
|
6
|
+
r.get("/")
|
|
7
|
+
.summary("List my gift cards")
|
|
8
|
+
.auth()
|
|
9
|
+
.handler(async ({ actor, orgId }) => {
|
|
10
|
+
if (!actor)
|
|
11
|
+
throw new Error("Unauthorized");
|
|
12
|
+
const result = await service.list(orgId, { purchaserId: actor.userId });
|
|
13
|
+
if (!result.ok)
|
|
14
|
+
throw new Error("Failed to list gift cards");
|
|
15
|
+
return result.value.map((card) => ({
|
|
16
|
+
...card,
|
|
17
|
+
displayCode: formatCode(card.code),
|
|
18
|
+
// Mask the full code for security — show only last 4 chars
|
|
19
|
+
maskedCode: `****-****-****-${card.code.slice(-4)}`,
|
|
20
|
+
}));
|
|
21
|
+
});
|
|
22
|
+
return r.routes();
|
|
23
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
2
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
3
|
+
export declare function buildPublicRoutes(service: GiftCardService, ctx: {
|
|
4
|
+
services?: Record<string, unknown>;
|
|
5
|
+
database?: {
|
|
6
|
+
db: unknown;
|
|
7
|
+
};
|
|
8
|
+
}): PluginRouteRegistration[];
|
|
9
|
+
//# sourceMappingURL=public.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/routes/public.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,+BAA+B,CAAC;AACrE,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAErE,wBAAgB,iBAAiB,CAC/B,OAAO,EAAE,eAAe,EACxB,GAAG,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAAC,QAAQ,CAAC,EAAE;QAAE,EAAE,EAAE,OAAO,CAAA;KAAE,CAAA;CAAE,GACtE,uBAAuB,EAAE,CAsB3B"}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
export function buildPublicRoutes(service, ctx) {
|
|
4
|
+
const r = router("Gift Cards", "/gift-cards", ctx);
|
|
5
|
+
// ─── Check Balance (Public) ───────────────────────────────────────
|
|
6
|
+
r.post("/check-balance")
|
|
7
|
+
.summary("Check gift card balance")
|
|
8
|
+
.description("Public endpoint — no authentication required. Rate-limited.")
|
|
9
|
+
.input(z.object({
|
|
10
|
+
code: z.string().min(4).max(30).describe("Gift card code (hyphens optional)"),
|
|
11
|
+
}))
|
|
12
|
+
.handler(async ({ input }) => {
|
|
13
|
+
const body = input;
|
|
14
|
+
// Public endpoint: codes are globally unique, no org scoping needed
|
|
15
|
+
const result = await service.checkBalance("_any", body.code);
|
|
16
|
+
if (!result.ok)
|
|
17
|
+
throw new Error(result.error);
|
|
18
|
+
return result.value;
|
|
19
|
+
});
|
|
20
|
+
return r.routes();
|
|
21
|
+
}
|