@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.
@@ -0,0 +1,16 @@
1
+ import type { loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers } from "./schema";
2
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
3
+ export type LoyaltyPoints = typeof loyaltyPoints.$inferSelect;
4
+ export type LoyaltyTransaction = typeof loyaltyTransactions.$inferSelect;
5
+ export type LoyaltyOffer = typeof loyaltyRedemptionOffers.$inferSelect;
6
+ export type Tier = "bronze" | "silver" | "gold" | "platinum";
7
+ export interface LoyaltyPluginOptions {
8
+ pointsPerDollar?: number;
9
+ tierThresholds?: {
10
+ silver: number;
11
+ gold: number;
12
+ platinum: number;
13
+ };
14
+ }
15
+ export declare const DEFAULT_LOYALTY_OPTIONS: Required<LoyaltyPluginOptions>;
16
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,mBAAmB,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAE5F,YAAY,EAAE,QAAQ,IAAI,EAAE,EAAE,MAAM,uBAAuB,CAAC;AAC5D,MAAM,MAAM,aAAa,GAAG,OAAO,aAAa,CAAC,YAAY,CAAC;AAC9D,MAAM,MAAM,kBAAkB,GAAG,OAAO,mBAAmB,CAAC,YAAY,CAAC;AACzE,MAAM,MAAM,YAAY,GAAG,OAAO,uBAAuB,CAAC,YAAY,CAAC;AACvE,MAAM,MAAM,IAAI,GAAG,QAAQ,GAAG,QAAQ,GAAG,MAAM,GAAG,UAAU,CAAC;AAE7D,MAAM,WAAW,oBAAoB;IACnC,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,cAAc,CAAC,EAAE;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACrE;AAED,eAAO,MAAM,uBAAuB,EAAE,QAAQ,CAAC,oBAAoB,CAGlE,CAAC"}
package/dist/types.js ADDED
@@ -0,0 +1,4 @@
1
+ export const DEFAULT_LOYALTY_OPTIONS = {
2
+ pointsPerDollar: 1,
3
+ tierThresholds: { silver: 500, gold: 1500, platinum: 3000 },
4
+ };
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@unifiedcommerce/plugin-loyalty",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": {
7
+ "types": "./dist/index.d.ts",
8
+ "default": "./dist/index.js"
9
+ }
10
+ },
11
+ "scripts": {
12
+ "build": "tsc -p tsconfig.build.json",
13
+ "check-types": "tsc --noEmit",
14
+ "test": "vitest run"
15
+ },
16
+ "dependencies": {
17
+ "@hono/zod-openapi": "^1.2.2",
18
+ "@unifiedcommerce/core": "*",
19
+ "drizzle-orm": "^0.45.1",
20
+ "hono": "^4.12.5"
21
+ },
22
+ "devDependencies": {
23
+ "@repo/eslint-config": "*",
24
+ "@repo/typescript-config": "*",
25
+ "@types/node": "^24.5.2",
26
+ "typescript": "5.9.2",
27
+ "vitest": "^3.2.4"
28
+ },
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "files": [
33
+ "dist",
34
+ "src",
35
+ "README.md"
36
+ ]
37
+ }
package/src/index.ts ADDED
@@ -0,0 +1,50 @@
1
+ import { defineCommercePlugin, resolveOrgId } from "@unifiedcommerce/core";
2
+ import { eq, and, sql } from "drizzle-orm";
3
+ import { loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers } from "./schema";
4
+ import { LoyaltyService } from "./services/loyalty-service";
5
+ import { buildLoyaltyRoutes } from "./routes/loyalty";
6
+ import type { Db, LoyaltyPluginOptions } from "./types";
7
+ import { DEFAULT_LOYALTY_OPTIONS } from "./types";
8
+
9
+ export type { LoyaltyPluginOptions, Db } from "./types";
10
+ export { LoyaltyService } from "./services/loyalty-service";
11
+
12
+ export function loyaltyPlugin(userOptions: LoyaltyPluginOptions = {}) {
13
+ const options: Required<LoyaltyPluginOptions> = { ...DEFAULT_LOYALTY_OPTIONS, ...userOptions };
14
+
15
+ return defineCommercePlugin({
16
+ id: "loyalty",
17
+ version: "1.0.0",
18
+ permissions: [
19
+ { scope: "loyalty:admin", description: "Create/manage redemption offers, view all loyalty data." },
20
+ ],
21
+ schema: () => ({ loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers }),
22
+ hooks: () => [{
23
+ key: "orders.afterCreate",
24
+ async handler(args: unknown) {
25
+ const { result, context } = args as {
26
+ result: { id: string; customerId?: string; grandTotal: number };
27
+ context: { actor?: { organizationId?: string | null } | null; logger: { info(msg: string, data?: unknown): void }; services: { database?: { db: unknown } } };
28
+ };
29
+ if (!result.customerId) return;
30
+ const rawDb = context.services.database?.db;
31
+ if (!rawDb) return;
32
+
33
+ const orgId = resolveOrgId(context.actor);
34
+ const pointsEarned = Math.floor((result.grandTotal / 100) * options.pointsPerDollar);
35
+ if (pointsEarned <= 0) return;
36
+
37
+ const db = rawDb as unknown as Db;
38
+ const service = new LoyaltyService(db, options.tierThresholds);
39
+ await service.earnPoints(orgId, result.customerId, pointsEarned, result.id);
40
+
41
+ context.logger.info("loyalty_points_awarded", { customerId: result.customerId, orgId, pointsEarned });
42
+ },
43
+ }],
44
+ routes: (ctx) => {
45
+ const db = ctx.database.db as unknown as Db;
46
+ if (!db) return [];
47
+ return buildLoyaltyRoutes(new LoyaltyService(db, options.tierThresholds), ctx);
48
+ },
49
+ });
50
+ }
@@ -0,0 +1,69 @@
1
+ import { router } from "@unifiedcommerce/core";
2
+ import { z } from "@hono/zod-openapi";
3
+ import type { LoyaltyService } from "../services/loyalty-service";
4
+ import type { PluginRouteRegistration } from "@unifiedcommerce/core";
5
+
6
+ export function buildLoyaltyRoutes(
7
+ service: LoyaltyService,
8
+ ctx: { services?: Record<string, unknown>; database?: { db: unknown } },
9
+ ): PluginRouteRegistration[] {
10
+ const r = router("Loyalty", "/loyalty", ctx);
11
+
12
+ r.get("/points/{customerId}").summary("Get loyalty points (admin)").permission("loyalty:admin")
13
+ .handler(async ({ params, orgId }) => {
14
+ const result = await service.getPoints(orgId, params.customerId!);
15
+ if (!result.ok) throw new Error("Failed");
16
+ if (!result.value) return { customerId: params.customerId, points: 0, tier: "bronze", message: "No points yet" };
17
+ return result.value;
18
+ });
19
+
20
+ r.get("/leaderboard").summary("Loyalty leaderboard").auth()
21
+ .handler(async ({ orgId }) => {
22
+ const result = await service.getLeaderboard(orgId);
23
+ if (!result.ok) throw new Error(result.error);
24
+ return result.value.map((e: { customerId: string; points: number; tier: string }, i: number) => ({ rank: i + 1, customerId: e.customerId, points: e.points, tier: e.tier }));
25
+ });
26
+
27
+ r.post("/redeem").summary("Redeem points (admin)").permission("loyalty:admin")
28
+ .input(z.object({ customerId: z.string().min(1), pointsToRedeem: z.number().int().min(1) }))
29
+ .handler(async ({ input, orgId }) => {
30
+ const body = input as { customerId: string; pointsToRedeem: number };
31
+ const result = await service.redeemPoints(orgId, body.customerId, body.pointsToRedeem);
32
+ if (!result.ok) throw new Error(result.error);
33
+ return { remainingPoints: result.value.points, tier: result.value.tier };
34
+ });
35
+
36
+ // ─── Offers ────────────────────────────────────────────────────
37
+
38
+ r.post("/offers").summary("Create redemption offer").permission("loyalty:admin")
39
+ .input(z.object({
40
+ name: z.string().min(1), pointsRequired: z.number().int().positive(),
41
+ rewardType: z.enum(["discount_percentage", "discount_fixed", "free_item", "free_shipping"]),
42
+ rewardValue: z.number().int(), rewardEntityId: z.string().uuid().optional(),
43
+ validFrom: z.string().optional(), validUntil: z.string().optional(),
44
+ maxRedemptions: z.number().int().positive().optional(),
45
+ }))
46
+ .handler(async ({ input, orgId }) => {
47
+ const result = await service.createOffer(orgId, input as Parameters<typeof service.createOffer>[1]);
48
+ if (!result.ok) throw new Error(result.error);
49
+ return result.value;
50
+ });
51
+
52
+ r.get("/offers").summary("List active offers").auth()
53
+ .handler(async ({ orgId }) => {
54
+ const result = await service.listOffers(orgId);
55
+ if (!result.ok) throw new Error(result.error);
56
+ return result.value;
57
+ });
58
+
59
+ r.post("/offers/{id}/redeem").summary("Redeem an offer (admin)").permission("loyalty:admin")
60
+ .input(z.object({ customerId: z.string().min(1) }))
61
+ .handler(async ({ params, input, orgId }) => {
62
+ const body = input as { customerId: string };
63
+ const result = await service.redeemOffer(orgId, body.customerId, params.id!);
64
+ if (!result.ok) throw new Error(result.error);
65
+ return result.value;
66
+ });
67
+
68
+ return r.routes();
69
+ }
package/src/schema.ts ADDED
@@ -0,0 +1,54 @@
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
+
5
+ export const loyaltyPoints = pgTable("loyalty_points", {
6
+ id: uuid("id").defaultRandom().primaryKey(),
7
+ organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
8
+ customerId: uuid("customer_id")
9
+ .notNull()
10
+ .references(() => customers.id, { onDelete: "cascade" }),
11
+ points: integer("points").notNull().default(0),
12
+ tier: text("tier", { enum: ["bronze", "silver", "gold", "platinum"] }).notNull().default("bronze"),
13
+ lifetimeSpend: integer("lifetime_spend").notNull().default(0),
14
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
15
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
16
+ }, (table) => ({
17
+ orgIdx: index("idx_loyalty_points_org").on(table.organizationId),
18
+ orgCustomerUnique: uniqueIndex("loyalty_points_org_customer_unique").on(table.organizationId, table.customerId),
19
+ }));
20
+
21
+ export const loyaltyTransactions = pgTable("loyalty_transactions", {
22
+ id: uuid("id").defaultRandom().primaryKey(),
23
+ organizationId: text("organization_id").notNull().references(() => organization.id, { onDelete: "cascade" }),
24
+ customerId: uuid("customer_id")
25
+ .notNull()
26
+ .references(() => customers.id, { onDelete: "cascade" }),
27
+ orderId: uuid("order_id"),
28
+ type: text("type", { enum: ["earn", "redeem"] }).notNull(),
29
+ amount: integer("amount").notNull(),
30
+ description: text("description"),
31
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
32
+ }, (table) => ({
33
+ orgIdx: index("idx_loyalty_transactions_org").on(table.organizationId),
34
+ customerIdx: index("idx_loyalty_transactions_customer").on(table.customerId),
35
+ }));
36
+
37
+ export const loyaltyRedemptionOffers = pgTable("loyalty_redemption_offers", {
38
+ id: uuid("id").defaultRandom().primaryKey(),
39
+ organizationId: text("organization_id").notNull(),
40
+ name: text("name").notNull(),
41
+ pointsRequired: integer("points_required").notNull(),
42
+ rewardType: text("reward_type", { enum: ["discount_percentage", "discount_fixed", "free_item", "free_shipping"] }).notNull(),
43
+ rewardValue: integer("reward_value").notNull(),
44
+ rewardEntityId: uuid("reward_entity_id"),
45
+ isActive: boolean("is_active").notNull().default(true),
46
+ validFrom: timestamp("valid_from", { withTimezone: true }),
47
+ validUntil: timestamp("valid_until", { withTimezone: true }),
48
+ maxRedemptions: integer("max_redemptions"),
49
+ timesRedeemed: integer("times_redeemed").notNull().default(0),
50
+ createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
51
+ updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
52
+ }, (table) => ({
53
+ orgIdx: index("idx_loyalty_offers_org").on(table.organizationId),
54
+ }));
@@ -0,0 +1,130 @@
1
+ import { eq, and, desc, sql } from "drizzle-orm";
2
+ import { Ok, Err } from "@unifiedcommerce/core";
3
+ import type { PluginResult } from "@unifiedcommerce/core";
4
+ import { loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers } from "../schema";
5
+ import type { Db, LoyaltyPoints, LoyaltyOffer, Tier } from "../types";
6
+
7
+ function calculateTier(points: number, thresholds: { silver: number; gold: number; platinum: number }): Tier {
8
+ if (points >= thresholds.platinum) return "platinum";
9
+ if (points >= thresholds.gold) return "gold";
10
+ if (points >= thresholds.silver) return "silver";
11
+ return "bronze";
12
+ }
13
+
14
+ export class LoyaltyService {
15
+ constructor(private db: Db, private thresholds: { silver: number; gold: number; platinum: number }) {}
16
+
17
+ async getPoints(orgId: string, customerId: string): Promise<PluginResult<LoyaltyPoints | null>> {
18
+ const rows = await this.db.select().from(loyaltyPoints)
19
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
20
+ return Ok(rows[0] ?? null);
21
+ }
22
+
23
+ async earnPoints(orgId: string, customerId: string, amount: number, orderId?: string): Promise<PluginResult<LoyaltyPoints>> {
24
+ if (amount <= 0) return Err("Amount must be positive");
25
+
26
+ await this.db.insert(loyaltyTransactions).values({
27
+ organizationId: orgId, customerId, orderId, type: "earn", amount,
28
+ description: `Earned ${amount} points`,
29
+ });
30
+
31
+ await this.db.insert(loyaltyPoints).values({
32
+ organizationId: orgId, customerId, points: amount, lifetimeSpend: 0,
33
+ tier: calculateTier(amount, this.thresholds),
34
+ }).onConflictDoUpdate({
35
+ target: [loyaltyPoints.organizationId, loyaltyPoints.customerId],
36
+ set: {
37
+ points: sql`${loyaltyPoints.points} + ${amount}`,
38
+ updatedAt: new Date(),
39
+ },
40
+ });
41
+
42
+ const [updated] = await this.db.select().from(loyaltyPoints)
43
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
44
+
45
+ if (updated) {
46
+ const newTier = calculateTier(updated.points, this.thresholds);
47
+ if (newTier !== updated.tier) {
48
+ await this.db.update(loyaltyPoints).set({ tier: newTier, updatedAt: new Date() })
49
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
50
+ return Ok({ ...updated, tier: newTier });
51
+ }
52
+ }
53
+
54
+ return Ok(updated!);
55
+ }
56
+
57
+ async redeemPoints(orgId: string, customerId: string, amount: number): Promise<PluginResult<LoyaltyPoints>> {
58
+ if (amount <= 0) return Err("Amount must be positive");
59
+
60
+ // Atomic check-and-deduct: lock the row to prevent concurrent double-spend
61
+ const [loyalty] = await this.db.select().from(loyaltyPoints)
62
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)))
63
+ .for("update");
64
+ if (!loyalty) return Err("No loyalty account found");
65
+ if (loyalty.points < amount) return Err("Not enough points");
66
+
67
+ const newPoints = loyalty.points - amount;
68
+ const newTier = calculateTier(newPoints, this.thresholds);
69
+
70
+ await this.db.update(loyaltyPoints).set({ points: newPoints, tier: newTier, updatedAt: new Date() })
71
+ .where(and(eq(loyaltyPoints.organizationId, orgId), eq(loyaltyPoints.customerId, customerId)));
72
+
73
+ await this.db.insert(loyaltyTransactions).values({
74
+ organizationId: orgId, customerId, type: "redeem", amount,
75
+ description: `Redeemed ${amount} points`,
76
+ });
77
+
78
+ return Ok({ ...loyalty, points: newPoints, tier: newTier });
79
+ }
80
+
81
+ async getLeaderboard(orgId: string, limit = 10): Promise<PluginResult<LoyaltyPoints[]>> {
82
+ const rows = await this.db.select().from(loyaltyPoints)
83
+ .where(eq(loyaltyPoints.organizationId, orgId))
84
+ .orderBy(desc(loyaltyPoints.points)).limit(limit);
85
+ return Ok(rows);
86
+ }
87
+
88
+ // ─── Offers ────────────────────────────────────────────────────────
89
+
90
+ async createOffer(orgId: string, input: {
91
+ name: string; pointsRequired: number; rewardType: "discount_percentage" | "discount_fixed" | "free_item" | "free_shipping";
92
+ rewardValue: number; rewardEntityId?: string; validFrom?: string; validUntil?: string; maxRedemptions?: number;
93
+ }): Promise<PluginResult<LoyaltyOffer>> {
94
+ const rows = await this.db.insert(loyaltyRedemptionOffers).values({
95
+ organizationId: orgId, name: input.name, pointsRequired: input.pointsRequired,
96
+ rewardType: input.rewardType, rewardValue: input.rewardValue,
97
+ rewardEntityId: input.rewardEntityId,
98
+ ...(input.validFrom ? { validFrom: new Date(input.validFrom) } : {}),
99
+ ...(input.validUntil ? { validUntil: new Date(input.validUntil) } : {}),
100
+ maxRedemptions: input.maxRedemptions,
101
+ }).returning();
102
+ return Ok(rows[0]!);
103
+ }
104
+
105
+ async listOffers(orgId: string): Promise<PluginResult<LoyaltyOffer[]>> {
106
+ const rows = await this.db.select().from(loyaltyRedemptionOffers)
107
+ .where(and(eq(loyaltyRedemptionOffers.organizationId, orgId), eq(loyaltyRedemptionOffers.isActive, true)));
108
+ return Ok(rows);
109
+ }
110
+
111
+ async redeemOffer(orgId: string, customerId: string, offerId: string): Promise<PluginResult<{ offer: LoyaltyOffer; remainingPoints: number }>> {
112
+ // Lock offer row to prevent concurrent over-redemption
113
+ const [offer] = await this.db.select().from(loyaltyRedemptionOffers)
114
+ .where(and(eq(loyaltyRedemptionOffers.id, offerId), eq(loyaltyRedemptionOffers.organizationId, orgId)))
115
+ .for("update");
116
+ if (!offer) return Err("Offer not found");
117
+ if (!offer.isActive) return Err("Offer is not active");
118
+ if (offer.maxRedemptions && offer.timesRedeemed >= offer.maxRedemptions) return Err("Offer fully redeemed");
119
+
120
+ // redeemPoints also uses FOR UPDATE — safe within same transaction context
121
+ const redeemResult = await this.redeemPoints(orgId, customerId, offer.pointsRequired);
122
+ if (!redeemResult.ok) return redeemResult;
123
+
124
+ await this.db.update(loyaltyRedemptionOffers)
125
+ .set({ timesRedeemed: sql`${loyaltyRedemptionOffers.timesRedeemed} + 1`, updatedAt: new Date() })
126
+ .where(eq(loyaltyRedemptionOffers.id, offerId));
127
+
128
+ return Ok({ offer, remainingPoints: redeemResult.value.points });
129
+ }
130
+ }
package/src/types.ts ADDED
@@ -0,0 +1,17 @@
1
+ import type { loyaltyPoints, loyaltyTransactions, loyaltyRedemptionOffers } from "./schema";
2
+
3
+ export type { PluginDb as Db } from "@unifiedcommerce/core";
4
+ export type LoyaltyPoints = typeof loyaltyPoints.$inferSelect;
5
+ export type LoyaltyTransaction = typeof loyaltyTransactions.$inferSelect;
6
+ export type LoyaltyOffer = typeof loyaltyRedemptionOffers.$inferSelect;
7
+ export type Tier = "bronze" | "silver" | "gold" | "platinum";
8
+
9
+ export interface LoyaltyPluginOptions {
10
+ pointsPerDollar?: number;
11
+ tierThresholds?: { silver: number; gold: number; platinum: number };
12
+ }
13
+
14
+ export const DEFAULT_LOYALTY_OPTIONS: Required<LoyaltyPluginOptions> = {
15
+ pointsPerDollar: 1,
16
+ tierThresholds: { silver: 500, gold: 1500, platinum: 3000 },
17
+ };