@unifiedcommerce/plugin-loyalty 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/schema.js ADDED
@@ -0,0 +1,51 @@
1
+ import { index, integer, boolean, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core";
2
+ import { customers } from "@unifiedcommerce/core/schema";
3
+ import { organization } from "@unifiedcommerce/core/auth-schema";
4
+ export const loyaltyPoints = pgTable("loyalty_points", {
5
+ id: uuid("id").defaultRandom().primaryKey(),
6
+ organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
7
+ customerId: uuid("customer_id")
8
+ .notNull()
9
+ .references(() => customers.id, { onDelete: "cascade" }),
10
+ points: integer("points").notNull().default(0),
11
+ tier: text("tier", { enum: ["bronze", "silver", "gold", "platinum"] }).notNull().default("bronze"),
12
+ lifetimeSpend: integer("lifetime_spend").notNull().default(0),
13
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
14
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
15
+ }, (table) => ({
16
+ orgIdx: index("idx_loyalty_points_org").on(table.organizationId),
17
+ orgCustomerUnique: uniqueIndex("loyalty_points_org_customer_unique").on(table.organizationId, table.customerId),
18
+ }));
19
+ export const loyaltyTransactions = pgTable("loyalty_transactions", {
20
+ id: uuid("id").defaultRandom().primaryKey(),
21
+ organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
22
+ customerId: uuid("customer_id")
23
+ .notNull()
24
+ .references(() => customers.id, { onDelete: "cascade" }),
25
+ orderId: uuid("order_id"),
26
+ type: text("type", { enum: ["earn", "redeem"] }).notNull(),
27
+ amount: integer("amount").notNull(),
28
+ description: text("description"),
29
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
30
+ }, (table) => ({
31
+ orgIdx: index("idx_loyalty_transactions_org").on(table.organizationId),
32
+ customerIdx: index("idx_loyalty_transactions_customer").on(table.customerId),
33
+ }));
34
+ export const loyaltyRedemptionOffers = pgTable("loyalty_redemption_offers", {
35
+ id: uuid("id").defaultRandom().primaryKey(),
36
+ organizationId: text("organization_id").notNull(),
37
+ name: text("name").notNull(),
38
+ pointsRequired: integer("points_required").notNull(),
39
+ rewardType: text("reward_type", { enum: ["discount_percentage", "discount_fixed", "free_item", "free_shipping"] }).notNull(),
40
+ rewardValue: integer("reward_value").notNull(),
41
+ rewardEntityId: uuid("reward_entity_id"),
42
+ isActive: boolean("is_active").notNull().default(true),
43
+ validFrom: timestamp("valid_from", { withTimezone: true }),
44
+ validUntil: timestamp("valid_until", { withTimezone: true }),
45
+ maxRedemptions: integer("max_redemptions"),
46
+ timesRedeemed: integer("times_redeemed").notNull().default(0),
47
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
48
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
49
+ }, (table) => ({
50
+ orgIdx: index("idx_loyalty_offers_org").on(table.organizationId),
51
+ }));
@@ -0,0 +1,31 @@
1
+ import type { PluginResult } from "@unifiedcommerce/core";
2
+ import type { Db, LoyaltyPoints, LoyaltyOffer } from "../types";
3
+ export declare class LoyaltyService {
4
+ private db;
5
+ private thresholds;
6
+ constructor(db: Db, thresholds: {
7
+ silver: number;
8
+ gold: number;
9
+ platinum: number;
10
+ });
11
+ getPoints(orgId: string, customerId: string): Promise<PluginResult<LoyaltyPoints | null>>;
12
+ earnPoints(orgId: string, customerId: string, amount: number, orderId?: string): Promise<PluginResult<LoyaltyPoints>>;
13
+ redeemPoints(orgId: string, customerId: string, amount: number): Promise<PluginResult<LoyaltyPoints>>;
14
+ getLeaderboard(orgId: string, limit?: number): Promise<PluginResult<LoyaltyPoints[]>>;
15
+ createOffer(orgId: string, input: {
16
+ name: string;
17
+ pointsRequired: number;
18
+ rewardType: "discount_percentage" | "discount_fixed" | "free_item" | "free_shipping";
19
+ rewardValue: number;
20
+ rewardEntityId?: string;
21
+ validFrom?: string;
22
+ validUntil?: string;
23
+ maxRedemptions?: number;
24
+ }): Promise<PluginResult<LoyaltyOffer>>;
25
+ listOffers(orgId: string): Promise<PluginResult<LoyaltyOffer[]>>;
26
+ redeemOffer(orgId: string, customerId: string, offerId: string): Promise<PluginResult<{
27
+ offer: LoyaltyOffer;
28
+ remainingPoints: number;
29
+ }>>;
30
+ }
31
+ //# sourceMappingURL=loyalty-service.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"loyalty-service.d.ts","sourceRoot":"","sources":["../../src/services/loyalty-service.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAE1D,OAAO,KAAK,EAAE,EAAE,EAAE,aAAa,EAAE,YAAY,EAAQ,MAAM,UAAU,CAAC;AAStE,qBAAa,cAAc;IACb,OAAO,CAAC,EAAE;IAAM,OAAO,CAAC,UAAU;gBAA1B,EAAE,EAAE,EAAE,EAAU,UAAU,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE;IAE5F,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,GAAG,IAAI,CAAC,CAAC;IAMzF,UAAU,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAkCrH,YAAY,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,CAAC,CAAC;IAwBrG,cAAc,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,SAAK,GAAG,OAAO,CAAC,YAAY,CAAC,aAAa,EAAE,CAAC,CAAC;IASjF,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE;QACtC,IAAI,EAAE,MAAM,CAAC;QAAC,cAAc,EAAE,MAAM,CAAC;QAAC,UAAU,EAAE,qBAAqB,GAAG,gBAAgB,GAAG,WAAW,GAAG,eAAe,CAAC;QAC3H,WAAW,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;QAAC,SAAS,CAAC,EAAE,MAAM,CAAC;QAAC,UAAU,CAAC,EAAE,MAAM,CAAC;QAAC,cAAc,CAAC,EAAE,MAAM,CAAC;KAChH,GAAG,OAAO,CAAC,YAAY,CAAC,YAAY,CAAC,CAAC;IAYjC,UAAU,CAAC,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC,YAAY,EAAE,CAAC,CAAC;IAMhE,WAAW,CAAC,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,YAAY,CAAC;QAAE,KAAK,EAAE,YAAY,CAAC;QAAC,eAAe,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;CAmB/I"}
@@ -0,0 +1,118 @@
1
+ import { eq, and, desc, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import { loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers } from "../schema";
4
+ function calculateTier(points, thresholds) {
5
+ if (points >= thresholds.platinum)
6
+ return "platinum";
7
+ if (points >= thresholds.gold)
8
+ return "gold";
9
+ if (points >= thresholds.silver)
10
+ return "silver";
11
+ return "bronze";
12
+ }
13
+ export class LoyaltyService {
14
+ db;
15
+ thresholds;
16
+ constructor(db, thresholds) {
17
+ this.db = db;
18
+ this.thresholds = thresholds;
19
+ }
20
+ async getPoints(orgId, customerId) {
21
+ const rows = await this.db.select().from(loyaltyPoints)
22
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
23
+ return Ok(rows[0] ?? null);
24
+ }
25
+ async earnPoints(orgId, customerId, amount, orderId) {
26
+ if (amount <= 0)
27
+ return Err("Amount must be positive");
28
+ await this.db.insert(loyaltyTransactions).values({
29
+ organizationId: orgId, customerId, orderId, type: "earn", amount,
30
+ description: `Earned ${amount} points`,
31
+ });
32
+ await this.db.insert(loyaltyPoints).values({
33
+ organizationId: orgId, customerId, points: amount, lifetimeSpend: 0,
34
+ tier: calculateTier(amount, this.thresholds),
35
+ }).onConflictDoUpdate({
36
+ target: [loyaltyPoints.organizationId, loyaltyPoints.customerId],
37
+ set: {
38
+ points: sql `${loyaltyPoints.points} + ${amount}`,
39
+ updatedAt: new Date(),
40
+ },
41
+ });
42
+ const [updated] = await this.db.select().from(loyaltyPoints)
43
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
44
+ if (updated) {
45
+ const newTier = calculateTier(updated.points, this.thresholds);
46
+ if (newTier !== updated.tier) {
47
+ await this.db.update(loyaltyPoints).set({ tier: newTier, updatedAt: new Date() })
48
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
49
+ return Ok({ ...updated, tier: newTier });
50
+ }
51
+ }
52
+ return Ok(updated);
53
+ }
54
+ async redeemPoints(orgId, customerId, amount) {
55
+ if (amount <= 0)
56
+ return Err("Amount must be positive");
57
+ // Atomic check-and-deduct: lock the row to prevent concurrent double-spend
58
+ const [loyalty] = await this.db.select().from(loyaltyPoints)
59
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)))
60
+ .for("update");
61
+ if (!loyalty)
62
+ return Err("No loyalty account found");
63
+ if (loyalty.points < amount)
64
+ return Err("Not enough points");
65
+ const newPoints = loyalty.points - amount;
66
+ const newTier = calculateTier(newPoints, this.thresholds);
67
+ await this.db.update(loyaltyPoints).set({ points: newPoints, tier: newTier, updatedAt: new Date() })
68
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
69
+ await this.db.insert(loyaltyTransactions).values({
70
+ organizationId: orgId, customerId, type: "redeem", amount,
71
+ description: `Redeemed ${amount} points`,
72
+ });
73
+ return Ok({ ...loyalty, points: newPoints, tier: newTier });
74
+ }
75
+ async getLeaderboard(orgId, limit = 10) {
76
+ const rows = await this.db.select().from(loyaltyPoints)
77
+ .where(eq(loyaltyPoints.organizationId, orgId))
78
+ .orderBy(desc(loyaltyPoints.points)).limit(limit);
79
+ return Ok(rows);
80
+ }
81
+ // ─── Offers ────────────────────────────────────────────────────────
82
+ async createOffer(orgId, input) {
83
+ const rows = await this.db.insert(loyaltyRedemptionOffers).values({
84
+ organizationId: orgId, name: input.name, pointsRequired: input.pointsRequired,
85
+ rewardType: input.rewardType, rewardValue: input.rewardValue,
86
+ rewardEntityId: input.rewardEntityId,
87
+ ...(input.validFrom ? { validFrom: new Date(input.validFrom) } : {}),
88
+ ...(input.validUntil ? { validUntil: new Date(input.validUntil) } : {}),
89
+ maxRedemptions: input.maxRedemptions,
90
+ }).returning();
91
+ return Ok(rows[0]);
92
+ }
93
+ async listOffers(orgId) {
94
+ const rows = await this.db.select().from(loyaltyRedemptionOffers)
95
+ .where(and(eq(loyaltyRedemptionOffers.organizationId, orgId), eq(loyaltyRedemptionOffers.isActive, true)));
96
+ return Ok(rows);
97
+ }
98
+ async redeemOffer(orgId, customerId, offerId) {
99
+ // Lock offer row to prevent concurrent over-redemption
100
+ const [offer] = await this.db.select().from(loyaltyRedemptionOffers)
101
+ .where(and(eq(loyaltyRedemptionOffers.id, offerId), eq(loyaltyRedemptionOffers.organizationId, orgId)))
102
+ .for("update");
103
+ if (!offer)
104
+ return Err("Offer not found");
105
+ if (!offer.isActive)
106
+ return Err("Offer is not active");
107
+ if (offer.maxRedemptions && offer.timesRedeemed >= offer.maxRedemptions)
108
+ return Err("Offer fully redeemed");
109
+ // redeemPoints also uses FOR UPDATE — safe within same transaction context
110
+ const redeemResult = await this.redeemPoints(orgId, customerId, offer.pointsRequired);
111
+ if (!redeemResult.ok)
112
+ return redeemResult;
113
+ await this.db.update(loyaltyRedemptionOffers)
114
+ .set({ timesRedeemed: sql `${loyaltyRedemptionOffers.timesRedeemed} + 1`, updatedAt: new Date() })
115
+ .where(eq(loyaltyRedemptionOffers.id, offerId));
116
+ return Ok({ offer, remainingPoints: redeemResult.value.points });
117
+ }
118
+ }