@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,45 @@
|
|
|
1
|
+
import type { PluginResult } from "@porulle/core";
|
|
2
|
+
import type { Db, GiftCard, GiftCardDeduction, GiftCardPluginOptions, GiftCardTransaction, GiftCardStatus } from "../types.js";
|
|
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,eAAe,CAAC;AAGlD,OAAO,KAAK,EACV,EAAE,EACF,QAAQ,EACR,iBAAiB,EACjB,qBAAqB,EACrB,mBAAmB,EACnB,cAAc,EAEf,MAAM,aAAa,CAAC;AAErB,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 "@porulle/core";
|
|
2
|
+
import { generateGiftCardCode, normalizeCode } from "../code-generator.js";
|
|
3
|
+
import { GiftCardRepository } from "./gift-card-repository.js";
|
|
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
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export type { PluginDb as Db } from "@porulle/core";
|
|
2
|
+
import type { giftCards, giftCardTransactions } from "./schema.js";
|
|
3
|
+
export type GiftCard = typeof giftCards.$inferSelect;
|
|
4
|
+
export type GiftCardInsert = typeof giftCards.$inferInsert;
|
|
5
|
+
export type GiftCardTransaction = typeof giftCardTransactions.$inferSelect;
|
|
6
|
+
export type GiftCardTransactionInsert = typeof giftCardTransactions.$inferInsert;
|
|
7
|
+
export type GiftCardStatus = "active" | "disabled" | "exhausted";
|
|
8
|
+
export type TransactionType = "debit" | "credit" | "refund";
|
|
9
|
+
export interface GiftCardPluginOptions {
|
|
10
|
+
/** Code format pattern. Default: "XXXX-XXXX-XXXX-XXXX" */
|
|
11
|
+
codeFormat?: string;
|
|
12
|
+
/** Default expiry duration in days. null = no expiry. Default: null */
|
|
13
|
+
defaultExpiryDays?: number | null;
|
|
14
|
+
/** Maximum balance per card in minor units. Default: 10_000_00 (100,000.00) */
|
|
15
|
+
maxBalancePerCard?: number;
|
|
16
|
+
/** Email template name for gift card delivery. Default: "gift-card-delivery" */
|
|
17
|
+
emailTemplate?: string;
|
|
18
|
+
/** Allow partial redemption. Default: true */
|
|
19
|
+
allowPartialRedemption?: boolean;
|
|
20
|
+
/** Entity type that triggers gift card issuance on purchase. Default: "gift_card" */
|
|
21
|
+
productType?: string;
|
|
22
|
+
}
|
|
23
|
+
export declare const DEFAULT_OPTIONS: Required<GiftCardPluginOptions>;
|
|
24
|
+
export interface GiftCardDeduction {
|
|
25
|
+
code: string;
|
|
26
|
+
giftCardId: string;
|
|
27
|
+
amount: number;
|
|
28
|
+
balanceAfter: number;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,eAAe,CAAC;AACpD,OAAO,KAAK,EAAE,SAAS,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAC;AAEnE,MAAM,MAAM,QAAQ,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AACrD,MAAM,MAAM,cAAc,GAAG,OAAO,SAAS,CAAC,YAAY,CAAC;AAC3D,MAAM,MAAM,mBAAmB,GAAG,OAAO,oBAAoB,CAAC,YAAY,CAAC;AAC3E,MAAM,MAAM,yBAAyB,GAAG,OAAO,oBAAoB,CAAC,YAAY,CAAC;AAEjF,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,UAAU,GAAG,WAAW,CAAC;AACjE,MAAM,MAAM,eAAe,GAAG,OAAO,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAE5D,MAAM,WAAW,qBAAqB;IACpC,0DAA0D;IAC1D,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uEAAuE;IACvE,iBAAiB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IAClC,+EAA+E;IAC/E,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gFAAgF;IAChF,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,8CAA8C;IAC9C,sBAAsB,CAAC,EAAE,OAAO,CAAC;IACjC,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,eAAO,MAAM,eAAe,EAAE,QAAQ,CAAC,qBAAqB,CAO3D,CAAC;AAEF,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,MAAM,CAAC;IACnB,MAAM,EAAE,MAAM,CAAC;IACf,YAAY,EAAE,MAAM,CAAC;CACtB"}
|
package/dist/types.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@porulle/plugin-giftcards",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"bun": "./src/index.ts",
|
|
9
|
+
"import": "./dist/index.js",
|
|
10
|
+
"types": "./src/index.ts"
|
|
11
|
+
},
|
|
12
|
+
"./schema": {
|
|
13
|
+
"bun": "./src/schema.ts",
|
|
14
|
+
"import": "./dist/schema.js",
|
|
15
|
+
"require": "./dist/schema.js",
|
|
16
|
+
"types": "./src/schema.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "rm -rf dist tsconfig.build.tsbuildinfo && tsc -p tsconfig.build.json",
|
|
21
|
+
"check-types": "tsc --noEmit",
|
|
22
|
+
"lint": "eslint . --max-warnings 1000",
|
|
23
|
+
"test": "vitest run"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@hono/zod-openapi": "^1.2.2",
|
|
27
|
+
"@porulle/core": "workspace:*",
|
|
28
|
+
"hono": "^4.12.5"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@repo/eslint-config": "*",
|
|
32
|
+
"@repo/typescript-config": "*",
|
|
33
|
+
"@types/node": "^24.5.2",
|
|
34
|
+
"eslint": "^9.39.1",
|
|
35
|
+
"typescript": "5.9.2",
|
|
36
|
+
"vitest": "^3.2.4"
|
|
37
|
+
},
|
|
38
|
+
"publishConfig": {
|
|
39
|
+
"access": "public"
|
|
40
|
+
},
|
|
41
|
+
"files": [
|
|
42
|
+
"src",
|
|
43
|
+
"dist",
|
|
44
|
+
"README.md"
|
|
45
|
+
],
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"zod": ">=4.0.0"
|
|
48
|
+
},
|
|
49
|
+
"description": "Stored-value gift cards with balance checks, checkout redemption, issuance from qualifying purchases, and admin lifecycle APIs.",
|
|
50
|
+
"homepage": "https://porulle-docs.vercel.app",
|
|
51
|
+
"bugs": {
|
|
52
|
+
"url": "https://github.com/asyncdotengineering/porulle/issues"
|
|
53
|
+
},
|
|
54
|
+
"repository": {
|
|
55
|
+
"type": "git",
|
|
56
|
+
"url": "git+https://github.com/asyncdotengineering/porulle.git",
|
|
57
|
+
"directory": "packages/plugins/plugin-gift-cards"
|
|
58
|
+
},
|
|
59
|
+
"author": "Porulle contributors"
|
|
60
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { randomBytes } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Allowed characters for gift card codes.
|
|
5
|
+
* Excludes visually ambiguous characters: 0, O, 1, I, L
|
|
6
|
+
* Charset size: 30 → 30^16 ≈ 7.2 × 10^23 possible codes
|
|
7
|
+
*/
|
|
8
|
+
const CHARSET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate a cryptographically secure gift card code.
|
|
12
|
+
*
|
|
13
|
+
* Format: XXXX-XXXX-XXXX-XXXX (16 alphanumeric chars)
|
|
14
|
+
* Uses crypto.randomBytes for uniform distribution.
|
|
15
|
+
*
|
|
16
|
+
* @param format - Pattern where X is replaced with a random char. Default: "XXXX-XXXX-XXXX-XXXX"
|
|
17
|
+
*/
|
|
18
|
+
export function generateGiftCardCode(format = "XXXX-XXXX-XXXX-XXXX"): string {
|
|
19
|
+
const charCount = (format.match(/X/g) ?? []).length;
|
|
20
|
+
// Request extra bytes to handle modulo bias rejection
|
|
21
|
+
const bytes = randomBytes(charCount * 2);
|
|
22
|
+
|
|
23
|
+
let byteIdx = 0;
|
|
24
|
+
let result = "";
|
|
25
|
+
|
|
26
|
+
for (const ch of format) {
|
|
27
|
+
if (ch === "X") {
|
|
28
|
+
// Rejection sampling to avoid modulo bias
|
|
29
|
+
// CHARSET.length = 30, so we reject values >= 240 (240 = 30 * 8)
|
|
30
|
+
let value: number;
|
|
31
|
+
do {
|
|
32
|
+
if (byteIdx >= bytes.length) {
|
|
33
|
+
// Extremely unlikely — generate more bytes
|
|
34
|
+
const extra = randomBytes(charCount);
|
|
35
|
+
for (let i = 0; i < extra.length; i++) {
|
|
36
|
+
bytes[byteIdx + i] = extra[i]!;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
value = bytes[byteIdx++]!;
|
|
40
|
+
} while (value >= 240); // 240 = 30 * 8, ensures uniform distribution
|
|
41
|
+
|
|
42
|
+
result += CHARSET[value % CHARSET.length]!;
|
|
43
|
+
} else {
|
|
44
|
+
result += ch;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return result;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Normalize a gift card code for database lookup.
|
|
53
|
+
* Strips hyphens/spaces and uppercases.
|
|
54
|
+
*/
|
|
55
|
+
export function normalizeCode(code: string): string {
|
|
56
|
+
return code.replace(/[-\s]/g, "").toUpperCase();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Format a raw code string into the display format.
|
|
61
|
+
*/
|
|
62
|
+
export function formatCode(raw: string, format = "XXXX-XXXX-XXXX-XXXX"): string {
|
|
63
|
+
const chars = raw.replace(/[-\s]/g, "").toUpperCase();
|
|
64
|
+
let charIdx = 0;
|
|
65
|
+
let result = "";
|
|
66
|
+
|
|
67
|
+
for (const ch of format) {
|
|
68
|
+
if (ch === "X" && charIdx < chars.length) {
|
|
69
|
+
result += chars[charIdx++];
|
|
70
|
+
} else if (ch !== "X") {
|
|
71
|
+
result += ch;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return result;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The character set used for code generation (for validation/testing) */
|
|
79
|
+
export const CODE_CHARSET = CHARSET;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { resolveOrgId } from "@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 CheckoutHookArgs {
|
|
12
|
+
data: {
|
|
13
|
+
total: number;
|
|
14
|
+
currency: string;
|
|
15
|
+
checkoutId: string;
|
|
16
|
+
metadata?: Record<string, unknown>;
|
|
17
|
+
};
|
|
18
|
+
context: HookContextLike;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface AfterCreateHookArgs {
|
|
22
|
+
data: { metadata?: Record<string, unknown>; checkoutId: string };
|
|
23
|
+
result: unknown;
|
|
24
|
+
context: HookContextLike;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* checkout.beforePayment hook — deducts gift card balances before the
|
|
29
|
+
* payment adapter authorizes the remaining amount.
|
|
30
|
+
*/
|
|
31
|
+
export function buildCheckoutDeductionHook(
|
|
32
|
+
service: GiftCardService,
|
|
33
|
+
): PluginHookRegistration {
|
|
34
|
+
const handler = async (args: CheckoutHookArgs) => {
|
|
35
|
+
const { data, context } = args;
|
|
36
|
+
const orgId = resolveOrgId(context.actor);
|
|
37
|
+
const codes = data.metadata?.giftCardCodes as string[] | undefined;
|
|
38
|
+
if (!codes?.length) return data;
|
|
39
|
+
|
|
40
|
+
let remaining = data.total;
|
|
41
|
+
const deductions: GiftCardDeduction[] = [];
|
|
42
|
+
|
|
43
|
+
for (const code of codes) {
|
|
44
|
+
if (remaining <= 0) break;
|
|
45
|
+
|
|
46
|
+
const balanceResult = await service.checkBalance(orgId, code);
|
|
47
|
+
if (!balanceResult.ok) continue;
|
|
48
|
+
|
|
49
|
+
const deductAmount = Math.min(remaining, balanceResult.value.balance);
|
|
50
|
+
if (deductAmount <= 0) continue;
|
|
51
|
+
|
|
52
|
+
const result = await service.debitWithLock(
|
|
53
|
+
orgId,
|
|
54
|
+
code,
|
|
55
|
+
deductAmount,
|
|
56
|
+
data.checkoutId,
|
|
57
|
+
data.currency,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
if (result.ok) {
|
|
61
|
+
deductions.push(result.value);
|
|
62
|
+
remaining -= deductAmount;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const giftCardTotal = deductions.reduce((sum, d) => sum + d.amount, 0);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
...data,
|
|
70
|
+
total: Math.max(0, remaining),
|
|
71
|
+
metadata: {
|
|
72
|
+
...data.metadata,
|
|
73
|
+
giftCardDeductions: deductions,
|
|
74
|
+
giftCardTotal,
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
key: "checkout.beforePayment",
|
|
81
|
+
handler: handler as (...args: unknown[]) => unknown,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* checkout.afterCreate hook — compensates gift card deductions if checkout fails.
|
|
87
|
+
*/
|
|
88
|
+
export function buildCheckoutCompensationHook(
|
|
89
|
+
service: GiftCardService,
|
|
90
|
+
): PluginHookRegistration {
|
|
91
|
+
const handler = async (args: AfterCreateHookArgs) => {
|
|
92
|
+
const { data, result, context } = args;
|
|
93
|
+
const orgId = resolveOrgId(context.actor);
|
|
94
|
+
if (!result) {
|
|
95
|
+
const deductions = data.metadata?.giftCardDeductions as
|
|
96
|
+
| GiftCardDeduction[]
|
|
97
|
+
| undefined;
|
|
98
|
+
|
|
99
|
+
for (const d of deductions ?? []) {
|
|
100
|
+
await service.creditWithLock(
|
|
101
|
+
orgId,
|
|
102
|
+
d.code,
|
|
103
|
+
d.amount,
|
|
104
|
+
data.checkoutId,
|
|
105
|
+
"Checkout failed — balance restored",
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
return {
|
|
112
|
+
key: "checkout.afterCreate",
|
|
113
|
+
handler: handler as (...args: unknown[]) => unknown,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { resolveOrgId } from "@porulle/core";
|
|
2
|
+
import type { PluginHookRegistration } from "@porulle/core";
|
|
3
|
+
import type { GiftCardService } from "../services/gift-card-service.js";
|
|
4
|
+
import type { GiftCardPluginOptions } from "../types.js";
|
|
5
|
+
|
|
6
|
+
interface OrderLineItem {
|
|
7
|
+
entityId: string;
|
|
8
|
+
entityType?: string;
|
|
9
|
+
quantity: number;
|
|
10
|
+
unitPrice?: number;
|
|
11
|
+
totalPrice?: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface OrderResult {
|
|
15
|
+
id: string;
|
|
16
|
+
customerId?: string | null;
|
|
17
|
+
currency: string;
|
|
18
|
+
lineItems?: OrderLineItem[];
|
|
19
|
+
metadata?: Record<string, unknown> | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface HookContextLike {
|
|
23
|
+
actor: { organizationId?: string | null; [key: string]: unknown } | null;
|
|
24
|
+
[key: string]: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface AfterCreateHookArgs {
|
|
28
|
+
data: unknown;
|
|
29
|
+
result: OrderResult | null;
|
|
30
|
+
context: HookContextLike;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* checkout.afterCreate hook — issues gift cards when a gift card product is purchased.
|
|
35
|
+
*/
|
|
36
|
+
export function buildGiftCardIssuanceHook(
|
|
37
|
+
service: GiftCardService,
|
|
38
|
+
options: Required<GiftCardPluginOptions>,
|
|
39
|
+
enqueueJob?: (slug: string, input: Record<string, unknown>) => Promise<string>,
|
|
40
|
+
): PluginHookRegistration {
|
|
41
|
+
const handler = async (args: AfterCreateHookArgs) => {
|
|
42
|
+
const { result, context } = args;
|
|
43
|
+
if (!result?.lineItems) return;
|
|
44
|
+
|
|
45
|
+
const orgId = resolveOrgId(context.actor);
|
|
46
|
+
|
|
47
|
+
for (const item of result.lineItems) {
|
|
48
|
+
if (item.entityType !== options.productType) continue;
|
|
49
|
+
|
|
50
|
+
const amount = item.totalPrice ?? (item.unitPrice ?? 0) * item.quantity;
|
|
51
|
+
if (amount <= 0) continue;
|
|
52
|
+
|
|
53
|
+
const orderMeta = result.metadata as Record<string, unknown> | null;
|
|
54
|
+
const recipientEmail = (orderMeta?.giftCardRecipientEmail as string) ?? undefined;
|
|
55
|
+
const senderName = (orderMeta?.giftCardSenderName as string) ?? undefined;
|
|
56
|
+
const personalMessage = (orderMeta?.giftCardPersonalMessage as string) ?? undefined;
|
|
57
|
+
|
|
58
|
+
const createInput: Parameters<typeof service.create>[1] = {
|
|
59
|
+
amount,
|
|
60
|
+
currency: result.currency,
|
|
61
|
+
sourceOrderId: result.id,
|
|
62
|
+
};
|
|
63
|
+
if (result.customerId) createInput.purchaserId = result.customerId;
|
|
64
|
+
if (recipientEmail) createInput.recipientEmail = recipientEmail;
|
|
65
|
+
if (senderName) createInput.senderName = senderName;
|
|
66
|
+
if (personalMessage) createInput.personalMessage = personalMessage;
|
|
67
|
+
|
|
68
|
+
const cardResult = await service.create(orgId, createInput);
|
|
69
|
+
|
|
70
|
+
if (cardResult.ok && enqueueJob && recipientEmail) {
|
|
71
|
+
try {
|
|
72
|
+
await enqueueJob("gift-card.deliver", {
|
|
73
|
+
giftCardId: cardResult.value.id,
|
|
74
|
+
code: cardResult.value.code,
|
|
75
|
+
amount,
|
|
76
|
+
currency: result.currency,
|
|
77
|
+
recipientEmail,
|
|
78
|
+
senderName: senderName ?? "",
|
|
79
|
+
personalMessage: personalMessage ?? "",
|
|
80
|
+
template: options.emailTemplate,
|
|
81
|
+
});
|
|
82
|
+
} catch {
|
|
83
|
+
// Email delivery failure should not break checkout
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
key: "checkout.afterCreate",
|
|
91
|
+
handler: handler as (...args: unknown[]) => unknown,
|
|
92
|
+
};
|
|
93
|
+
}
|