@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,30 @@
|
|
|
1
|
+
import { router } from "@unifiedcommerce/core";
|
|
2
|
+
import type { GiftCardService } from "../services/gift-card-service";
|
|
3
|
+
import type { PluginRouteRegistration } from "@unifiedcommerce/core";
|
|
4
|
+
import { formatCode } from "../code-generator";
|
|
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";
|
|
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";
|
|
3
|
+
import type { Db, GiftCard, GiftCardInsert, GiftCardTransaction, GiftCardStatus, TransactionType } from "../types";
|
|
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";
|
|
4
|
+
import { GiftCardRepository } from "./gift-card-repository";
|
|
5
|
+
import type {
|
|
6
|
+
Db,
|
|
7
|
+
GiftCard,
|
|
8
|
+
GiftCardDeduction,
|
|
9
|
+
GiftCardPluginOptions,
|
|
10
|
+
GiftCardTransaction,
|
|
11
|
+
GiftCardStatus,
|
|
12
|
+
TransactionType,
|
|
13
|
+
} from "../types";
|
|
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";
|
|
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: null as unknown 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
|
+
}
|