@porulle/plugin-giftcards 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -0
- 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 +111 -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/types.d.ts +30 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +8 -0
- package/package.json +60 -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 +148 -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,56 @@
|
|
|
1
|
+
import { resolveOrgId } from "@porulle/core";
|
|
2
|
+
import type { PluginHookRegistration } from "@porulle/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,148 @@
|
|
|
1
|
+
import { defineCommercePlugin } from "@porulle/core";
|
|
2
|
+
import { giftCards, giftCardTransactions } from "./schema.js";
|
|
3
|
+
import { GiftCardService } from "./services/gift-card-service.js";
|
|
4
|
+
import {
|
|
5
|
+
buildCheckoutDeductionHook,
|
|
6
|
+
buildCheckoutCompensationHook,
|
|
7
|
+
} from "./hooks/checkout-deduction.js";
|
|
8
|
+
import { buildGiftCardIssuanceHook } from "./hooks/checkout-issuance.js";
|
|
9
|
+
import { buildRefundCreditHook } from "./hooks/refund-credit.js";
|
|
10
|
+
import { buildAdminRoutes } from "./routes/admin.js";
|
|
11
|
+
import { buildPublicRoutes } from "./routes/public.js";
|
|
12
|
+
import { buildCustomerRoutes } from "./routes/customer.js";
|
|
13
|
+
import type { GiftCardPluginOptions } from "./types.js";
|
|
14
|
+
import { DEFAULT_OPTIONS } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export type { GiftCardPluginOptions } from "./types.js";
|
|
17
|
+
export { GiftCardService } from "./services/gift-card-service.js";
|
|
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;
|
|
53
|
+
if (!db) return [];
|
|
54
|
+
|
|
55
|
+
const service = new GiftCardService(
|
|
56
|
+
db,
|
|
57
|
+
ctx.database.transaction,
|
|
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
|
+
// Lazy proxy: defers to serviceRef.current once routes() initializes it.
|
|
108
|
+
// Uses Reflect.get for type-safe dynamic property access (no index signature needed).
|
|
109
|
+
const lazyService = new Proxy({} as GiftCardService, {
|
|
110
|
+
get(_target, prop, receiver) {
|
|
111
|
+
if (!serviceRef.current) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
"Gift card service not initialized — hooks ran before routes()",
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return Reflect.get(serviceRef.current, prop, receiver);
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
return [
|
|
121
|
+
buildCheckoutDeductionHook(lazyService),
|
|
122
|
+
buildCheckoutCompensationHook(lazyService),
|
|
123
|
+
buildGiftCardIssuanceHook(lazyService, options),
|
|
124
|
+
buildRefundCreditHook(lazyService),
|
|
125
|
+
];
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
routes: (ctx) => {
|
|
129
|
+
const db = ctx.database.db;
|
|
130
|
+
if (!db) return [];
|
|
131
|
+
|
|
132
|
+
const service = new GiftCardService(
|
|
133
|
+
db,
|
|
134
|
+
ctx.database.transaction,
|
|
135
|
+
options,
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
// Wire up the shared reference so hooks can access the service
|
|
139
|
+
serviceRef.current = service;
|
|
140
|
+
|
|
141
|
+
return [
|
|
142
|
+
...buildAdminRoutes(service, ctx),
|
|
143
|
+
...buildPublicRoutes(service, ctx),
|
|
144
|
+
...buildCustomerRoutes(service, ctx),
|
|
145
|
+
];
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { router } from "@porulle/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { GiftCardService } from "../services/gift-card-service.js";
|
|
4
|
+
import type { PluginRouteRegistration } from "@porulle/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 "@porulle/core";
|
|
2
|
+
import type { GiftCardService } from "../services/gift-card-service.js";
|
|
3
|
+
import type { PluginRouteRegistration } from "@porulle/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 "@porulle/core";
|
|
2
|
+
import { z } from "@hono/zod-openapi";
|
|
3
|
+
import type { GiftCardService } from "../services/gift-card-service.js";
|
|
4
|
+
import type { PluginRouteRegistration } from "@porulle/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 "@porulle/core/drizzle";
|
|
13
|
+
import { sql } from "@porulle/core/drizzle";
|
|
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 "@porulle/core/drizzle";
|
|
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
|
+
}
|