@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,45 @@
|
|
|
1
|
+
import type { PluginResult } from "@unifiedcommerce/core";
|
|
2
|
+
import type { Db, GiftCard, GiftCardDeduction, GiftCardPluginOptions, GiftCardTransaction, GiftCardStatus } from "../types";
|
|
3
|
+
export declare class GiftCardService {
|
|
4
|
+
private options;
|
|
5
|
+
private repo;
|
|
6
|
+
private transaction;
|
|
7
|
+
constructor(db: Db, transactionFn: (fn: (tx: Db) => Promise<unknown>) => Promise<unknown>, options: Required<GiftCardPluginOptions>);
|
|
8
|
+
create(orgId: string, input: {
|
|
9
|
+
amount: number;
|
|
10
|
+
currency: string;
|
|
11
|
+
purchaserId?: string;
|
|
12
|
+
recipientEmail?: string;
|
|
13
|
+
senderName?: string;
|
|
14
|
+
personalMessage?: string;
|
|
15
|
+
sourceOrderId?: string;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
}): Promise<PluginResult<GiftCard>>;
|
|
18
|
+
getById(orgId: string, id: string): Promise<PluginResult<GiftCard>>;
|
|
19
|
+
getByCode(orgId: string, code: string): Promise<PluginResult<GiftCard>>;
|
|
20
|
+
list(orgId: string, filters?: {
|
|
21
|
+
status?: GiftCardStatus;
|
|
22
|
+
purchaserId?: string;
|
|
23
|
+
}): Promise<PluginResult<GiftCard[]>>;
|
|
24
|
+
getTransactions(orgId: string, giftCardId: string): Promise<PluginResult<GiftCardTransaction[]>>;
|
|
25
|
+
checkBalance(orgId: string, code: string): Promise<PluginResult<{
|
|
26
|
+
balance: number;
|
|
27
|
+
currency: string;
|
|
28
|
+
status: string;
|
|
29
|
+
}>>;
|
|
30
|
+
/**
|
|
31
|
+
* Debit a gift card balance within a transaction.
|
|
32
|
+
* Uses SELECT FOR UPDATE to prevent double-spend.
|
|
33
|
+
*/
|
|
34
|
+
debitWithLock(orgId: string, code: string, amount: number, orderId: string, currency: string): Promise<PluginResult<GiftCardDeduction>>;
|
|
35
|
+
/**
|
|
36
|
+
* Credit a gift card balance (refund/compensation).
|
|
37
|
+
* Uses SELECT FOR UPDATE. Cannot exceed initial_amount.
|
|
38
|
+
*/
|
|
39
|
+
creditWithLock(orgId: string, code: string, amount: number, orderId: string, note: string): Promise<PluginResult<{
|
|
40
|
+
balanceAfter: number;
|
|
41
|
+
}>>;
|
|
42
|
+
disable(orgId: string, id: string): Promise<PluginResult<GiftCard>>;
|
|
43
|
+
adjust(orgId: string, id: string, delta: number, note: string): Promise<PluginResult<GiftCard>>;
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=gift-card-service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"gift-card-service.d.ts","sourceRoot":"","sources":["../../src/services/gift-card-service.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAG1D,OAAO,KAAK,EACV,EAAE,EACF,QAAQ,EACR,iBAAiB,EACjB,qBAAqB,EACrB,mBAAmB,EACnB,cAAc,EAEf,MAAM,UAAU,CAAC;AAElB,qBAAa,eAAe;IAOxB,OAAO,CAAC,OAAO;IANjB,OAAO,CAAC,IAAI,CAAqB;IACjC,OAAO,CAAC,WAAW,CAAyD;gBAG1E,EAAE,EAAE,EAAE,EACN,aAAa,EAAE,CAAC,EAAE,EAAE,CAAC,EAAE,EAAE,EAAE,KAAK,OAAO,CAAC,OAAO,CAAC,KAAK,OAAO,CAAC,OAAO,CAAC,EAC7D,OAAO,EAAE,QAAQ,CAAC,qBAAqB,CAAC;IAQ5C,MAAM,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACjC,MAAM,EAAE,MAAM,CAAC;QACf,QAAQ,EAAE,MAAM,CAAC;QACjB,WAAW,CAAC,EAAE,MAAM,CAAC;QACrB,cAAc,CAAC,EAAE,MAAM,CAAC;QACxB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,eAAe,CAAC,EAAE,MAAM,CAAC;QACzB,aAAa,CAAC,EAAE,MAAM,CAAC;QACvB,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;KACpC,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;IAuD7B,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;IAMnE,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;IAMvE,IAAI,CAAC,KAAK,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE;QAClC,MAAM,CAAC,EAAE,cAAc,CAAC;QACxB,WAAW,CAAC,EAAE,MAAM,CAAC;KACtB,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,EAAE,CAAC,CAAC;IAK/B,eAAe,CACnB,KAAK,EAAE,MAAM,EACb,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,YAAY,CAAC,mBAAmB,EAAE,CAAC,CAAC;IAOzC,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QACpE,OAAO,EAAE,MAAM,CAAC;QAChB,QAAQ,EAAE,MAAM,CAAC;QACjB,MAAM,EAAE,MAAM,CAAC;KAChB,CAAC,CAAC;IAaH;;;OAGG;IACG,aAAa,CACjB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,YAAY,CAAC,iBAAiB,CAAC,CAAC;IAwC3C;;;OAGG;IACG,cAAc,CAClB,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,EACZ,MAAM,EAAE,MAAM,EACd,OAAO,EAAE,MAAM,EACf,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC;QAAE,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAsC5C,OAAO,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;IAMnE,MAAM,CACV,KAAK,EAAE,MAAM,EACb,EAAE,EAAE,MAAM,EACV,KAAK,EAAE,MAAM,EACb,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,YAAY,CAAC,QAAQ,CAAC,CAAC;CAoCnC"}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { Ok, Err } from "@unifiedcommerce/core";
|
|
2
|
+
import { generateGiftCardCode, normalizeCode } from "../code-generator";
|
|
3
|
+
import { GiftCardRepository } from "./gift-card-repository";
|
|
4
|
+
export class GiftCardService {
|
|
5
|
+
options;
|
|
6
|
+
repo;
|
|
7
|
+
transaction;
|
|
8
|
+
constructor(db, transactionFn, options) {
|
|
9
|
+
this.options = options;
|
|
10
|
+
this.repo = new GiftCardRepository(db);
|
|
11
|
+
this.transaction = transactionFn;
|
|
12
|
+
}
|
|
13
|
+
// ─── Create ──────────────────────────────────────────────────────────
|
|
14
|
+
async create(orgId, input) {
|
|
15
|
+
if (input.amount <= 0) {
|
|
16
|
+
return Err("Amount must be positive");
|
|
17
|
+
}
|
|
18
|
+
if (input.amount > this.options.maxBalancePerCard) {
|
|
19
|
+
return Err(`Amount exceeds maximum of ${this.options.maxBalancePerCard}`);
|
|
20
|
+
}
|
|
21
|
+
// Generate unique code with collision retry
|
|
22
|
+
let code;
|
|
23
|
+
let attempts = 0;
|
|
24
|
+
do {
|
|
25
|
+
code = normalizeCode(generateGiftCardCode(this.options.codeFormat));
|
|
26
|
+
const existing = await this.repo.findByCode(code);
|
|
27
|
+
if (!existing)
|
|
28
|
+
break;
|
|
29
|
+
attempts++;
|
|
30
|
+
} while (attempts < 10);
|
|
31
|
+
if (attempts >= 10) {
|
|
32
|
+
return Err("Failed to generate unique code after 10 attempts");
|
|
33
|
+
}
|
|
34
|
+
const expiresAt = this.options.defaultExpiryDays
|
|
35
|
+
? new Date(Date.now() + this.options.defaultExpiryDays * 24 * 60 * 60 * 1000)
|
|
36
|
+
: undefined;
|
|
37
|
+
const card = await this.repo.create({
|
|
38
|
+
organizationId: orgId,
|
|
39
|
+
code,
|
|
40
|
+
initialAmount: input.amount,
|
|
41
|
+
balance: input.amount,
|
|
42
|
+
currency: input.currency.toUpperCase(),
|
|
43
|
+
purchaserId: input.purchaserId,
|
|
44
|
+
recipientEmail: input.recipientEmail,
|
|
45
|
+
senderName: input.senderName,
|
|
46
|
+
personalMessage: input.personalMessage,
|
|
47
|
+
sourceOrderId: input.sourceOrderId,
|
|
48
|
+
expiresAt,
|
|
49
|
+
metadata: input.metadata ?? {},
|
|
50
|
+
});
|
|
51
|
+
// Record initial credit transaction
|
|
52
|
+
await this.repo.recordTransaction({
|
|
53
|
+
giftCardId: card.id,
|
|
54
|
+
type: "credit",
|
|
55
|
+
amount: input.amount,
|
|
56
|
+
balanceAfter: input.amount,
|
|
57
|
+
note: "Initial load",
|
|
58
|
+
});
|
|
59
|
+
return Ok(card);
|
|
60
|
+
}
|
|
61
|
+
// ─── Query ───────────────────────────────────────────────────────────
|
|
62
|
+
async getById(orgId, id) {
|
|
63
|
+
const card = await this.repo.findById(id);
|
|
64
|
+
if (!card)
|
|
65
|
+
return Err("Gift card not found");
|
|
66
|
+
return Ok(card);
|
|
67
|
+
}
|
|
68
|
+
async getByCode(orgId, code) {
|
|
69
|
+
const card = await this.repo.findByCode(normalizeCode(code));
|
|
70
|
+
if (!card)
|
|
71
|
+
return Err("Gift card not found");
|
|
72
|
+
return Ok(card);
|
|
73
|
+
}
|
|
74
|
+
async list(orgId, filters) {
|
|
75
|
+
const cards = await this.repo.list(filters);
|
|
76
|
+
return Ok(cards);
|
|
77
|
+
}
|
|
78
|
+
async getTransactions(orgId, giftCardId) {
|
|
79
|
+
const txns = await this.repo.listTransactions(giftCardId);
|
|
80
|
+
return Ok(txns);
|
|
81
|
+
}
|
|
82
|
+
// ─── Balance Check (Public) ──────────────────────────────────────────
|
|
83
|
+
async checkBalance(orgId, code) {
|
|
84
|
+
const card = await this.repo.findByCode(normalizeCode(code));
|
|
85
|
+
if (!card)
|
|
86
|
+
return Err("Gift card not found");
|
|
87
|
+
return Ok({
|
|
88
|
+
balance: card.balance,
|
|
89
|
+
currency: card.currency,
|
|
90
|
+
status: card.status,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
// ─── Debit (Concurrency-Safe) ────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Debit a gift card balance within a transaction.
|
|
96
|
+
* Uses SELECT FOR UPDATE to prevent double-spend.
|
|
97
|
+
*/
|
|
98
|
+
async debitWithLock(orgId, code, amount, orderId, currency) {
|
|
99
|
+
if (amount <= 0)
|
|
100
|
+
return Err("Debit amount must be positive");
|
|
101
|
+
const result = await this.transaction(async (tx) => {
|
|
102
|
+
const card = await this.repo.findByCodeForUpdate(normalizeCode(code), tx);
|
|
103
|
+
if (!card)
|
|
104
|
+
return Err("GIFT_CARD_NOT_FOUND");
|
|
105
|
+
if (card.status === "disabled")
|
|
106
|
+
return Err("GIFT_CARD_INACTIVE");
|
|
107
|
+
if (card.status === "exhausted")
|
|
108
|
+
return Err("GIFT_CARD_EXHAUSTED");
|
|
109
|
+
if (card.expiresAt && card.expiresAt < new Date())
|
|
110
|
+
return Err("GIFT_CARD_EXPIRED");
|
|
111
|
+
if (card.currency !== currency.toUpperCase())
|
|
112
|
+
return Err("CURRENCY_MISMATCH");
|
|
113
|
+
if (card.balance < amount)
|
|
114
|
+
return Err("INSUFFICIENT_BALANCE");
|
|
115
|
+
const balanceAfter = card.balance - amount;
|
|
116
|
+
const newStatus = balanceAfter === 0 ? "exhausted" : "active";
|
|
117
|
+
await this.repo.updateBalance(card.id, balanceAfter, newStatus, card.version, tx);
|
|
118
|
+
await this.repo.recordTransaction({
|
|
119
|
+
giftCardId: card.id,
|
|
120
|
+
type: "debit",
|
|
121
|
+
amount,
|
|
122
|
+
balanceAfter,
|
|
123
|
+
orderId,
|
|
124
|
+
}, { tx });
|
|
125
|
+
return Ok({
|
|
126
|
+
code: card.code,
|
|
127
|
+
giftCardId: card.id,
|
|
128
|
+
amount,
|
|
129
|
+
balanceAfter,
|
|
130
|
+
});
|
|
131
|
+
});
|
|
132
|
+
return result;
|
|
133
|
+
}
|
|
134
|
+
// ─── Credit (Concurrency-Safe) ───────────────────────────────────────
|
|
135
|
+
/**
|
|
136
|
+
* Credit a gift card balance (refund/compensation).
|
|
137
|
+
* Uses SELECT FOR UPDATE. Cannot exceed initial_amount.
|
|
138
|
+
*/
|
|
139
|
+
async creditWithLock(orgId, code, amount, orderId, note) {
|
|
140
|
+
if (amount <= 0)
|
|
141
|
+
return Err("Credit amount must be positive");
|
|
142
|
+
const result = await this.transaction(async (tx) => {
|
|
143
|
+
const card = await this.repo.findByCodeForUpdate(normalizeCode(code), tx);
|
|
144
|
+
if (!card)
|
|
145
|
+
return Err("GIFT_CARD_NOT_FOUND");
|
|
146
|
+
// Cap credit at initial amount (prevent inflation attack)
|
|
147
|
+
const balanceAfter = Math.min(card.initialAmount, card.balance + amount);
|
|
148
|
+
const actualCredit = balanceAfter - card.balance;
|
|
149
|
+
if (actualCredit <= 0) {
|
|
150
|
+
return Ok({ balanceAfter: card.balance });
|
|
151
|
+
}
|
|
152
|
+
const newStatus = balanceAfter > 0 ? "active" : card.status;
|
|
153
|
+
await this.repo.updateBalance(card.id, balanceAfter, newStatus, card.version, tx);
|
|
154
|
+
await this.repo.recordTransaction({
|
|
155
|
+
giftCardId: card.id,
|
|
156
|
+
type: "refund",
|
|
157
|
+
amount: actualCredit,
|
|
158
|
+
balanceAfter,
|
|
159
|
+
orderId,
|
|
160
|
+
note,
|
|
161
|
+
}, { tx });
|
|
162
|
+
return Ok({ balanceAfter });
|
|
163
|
+
});
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
// ─── Admin Operations ────────────────────────────────────────────────
|
|
167
|
+
async disable(orgId, id) {
|
|
168
|
+
const card = await this.repo.disable(id);
|
|
169
|
+
if (!card)
|
|
170
|
+
return Err("Gift card not found");
|
|
171
|
+
return Ok(card);
|
|
172
|
+
}
|
|
173
|
+
async adjust(orgId, id, delta, note) {
|
|
174
|
+
const result = await this.transaction(async (tx) => {
|
|
175
|
+
const card = await this.repo.findByIdForUpdate(id, tx);
|
|
176
|
+
if (!card)
|
|
177
|
+
return Err("Gift card not found");
|
|
178
|
+
const newBalance = Math.max(0, Math.min(card.initialAmount, card.balance + delta));
|
|
179
|
+
const actualDelta = newBalance - card.balance;
|
|
180
|
+
const newStatus = newBalance === 0 ? "exhausted" : "active";
|
|
181
|
+
const updated = await this.repo.updateBalance(card.id, newBalance, newStatus, card.version, tx);
|
|
182
|
+
if (actualDelta !== 0) {
|
|
183
|
+
const txnType = actualDelta > 0 ? "credit" : "debit";
|
|
184
|
+
await this.repo.recordTransaction({
|
|
185
|
+
giftCardId: card.id,
|
|
186
|
+
type: txnType,
|
|
187
|
+
amount: Math.abs(actualDelta),
|
|
188
|
+
balanceAfter: newBalance,
|
|
189
|
+
note,
|
|
190
|
+
}, { tx });
|
|
191
|
+
}
|
|
192
|
+
return Ok(updated);
|
|
193
|
+
});
|
|
194
|
+
return result;
|
|
195
|
+
}
|
|
196
|
+
}
|