@btst/plugin-affiliate 0.0.0-stage → 1.0.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.
Files changed (79) hide show
  1. package/LICENSE.md +17 -0
  2. package/dist/api/getters.cjs +182 -0
  3. package/dist/api/getters.mjs +166 -0
  4. package/dist/api/index.cjs +49 -0
  5. package/dist/api/index.d.cts +975 -0
  6. package/dist/api/index.d.mts +975 -0
  7. package/dist/api/index.d.ts +975 -0
  8. package/dist/api/index.mjs +7 -0
  9. package/dist/api/mutations.cjs +362 -0
  10. package/dist/api/mutations.mjs +352 -0
  11. package/dist/api/operations.cjs +328 -0
  12. package/dist/api/operations.mjs +326 -0
  13. package/dist/api/plugin.cjs +146 -0
  14. package/dist/api/plugin.mjs +144 -0
  15. package/dist/attribution.cjs +92 -0
  16. package/dist/attribution.d.cts +20 -0
  17. package/dist/attribution.d.mts +20 -0
  18. package/dist/attribution.d.ts +20 -0
  19. package/dist/attribution.mjs +82 -0
  20. package/dist/client/components/index.cjs +12 -0
  21. package/dist/client/components/index.d.cts +13 -0
  22. package/dist/client/components/index.d.mts +13 -0
  23. package/dist/client/components/index.d.ts +13 -0
  24. package/dist/client/components/index.mjs +1 -0
  25. package/dist/client/components/pages/affiliate-apply-page.internal.cjs +59 -0
  26. package/dist/client/components/pages/affiliate-apply-page.internal.mjs +57 -0
  27. package/dist/client/components/pages/affiliate-detail-page.internal.cjs +112 -0
  28. package/dist/client/components/pages/affiliate-detail-page.internal.mjs +110 -0
  29. package/dist/client/components/pages/affiliate-portal-page.internal.cjs +252 -0
  30. package/dist/client/components/pages/affiliate-portal-page.internal.mjs +250 -0
  31. package/dist/client/components/pages/affiliates-page.internal.cjs +160 -0
  32. package/dist/client/components/pages/affiliates-page.internal.mjs +158 -0
  33. package/dist/client/components/pages/commissions-page.internal.cjs +151 -0
  34. package/dist/client/components/pages/commissions-page.internal.mjs +149 -0
  35. package/dist/client/components/pages/payouts-page.internal.cjs +230 -0
  36. package/dist/client/components/pages/payouts-page.internal.mjs +228 -0
  37. package/dist/client/components/pages.cjs +145 -0
  38. package/dist/client/components/pages.mjs +138 -0
  39. package/dist/client/components/shared.cjs +331 -0
  40. package/dist/client/components/shared.mjs +316 -0
  41. package/dist/client/constants.cjs +5 -0
  42. package/dist/client/constants.mjs +3 -0
  43. package/dist/client/hooks/index.cjs +77 -0
  44. package/dist/client/hooks/index.d.cts +39 -0
  45. package/dist/client/hooks/index.d.mts +39 -0
  46. package/dist/client/hooks/index.d.ts +39 -0
  47. package/dist/client/hooks/index.mjs +61 -0
  48. package/dist/client/index.cjs +7 -0
  49. package/dist/client/index.d.cts +110 -0
  50. package/dist/client/index.d.mts +110 -0
  51. package/dist/client/index.d.ts +110 -0
  52. package/dist/client/index.mjs +1 -0
  53. package/dist/client/plugin.cjs +77 -0
  54. package/dist/client/plugin.mjs +75 -0
  55. package/dist/client.css +311 -0
  56. package/dist/commission.cjs +24 -0
  57. package/dist/commission.mjs +22 -0
  58. package/dist/db.cjs +74 -0
  59. package/dist/db.mjs +72 -0
  60. package/dist/permissions.cjs +53 -0
  61. package/dist/permissions.d.cts +78 -0
  62. package/dist/permissions.d.mts +78 -0
  63. package/dist/permissions.d.ts +78 -0
  64. package/dist/permissions.mjs +51 -0
  65. package/dist/query-keys.cjs +152 -0
  66. package/dist/query-keys.d.cts +394 -0
  67. package/dist/query-keys.d.mts +394 -0
  68. package/dist/query-keys.d.ts +394 -0
  69. package/dist/query-keys.mjs +149 -0
  70. package/dist/schemas.cjs +95 -0
  71. package/dist/schemas.mjs +77 -0
  72. package/dist/style.css +313 -0
  73. package/dist/types.cjs +2 -0
  74. package/dist/types.d.cts +176 -0
  75. package/dist/types.d.mts +176 -0
  76. package/dist/types.d.ts +176 -0
  77. package/dist/types.mjs +1 -0
  78. package/package.json +159 -3
  79. package/README.md +0 -3
@@ -0,0 +1,352 @@
1
+ import { OperationHttpError } from '@btst/stack/plugins/api';
2
+ import { isClickWithinWindow, safeLandingPath } from '../attribution.mjs';
3
+ import { calculateCommission } from '../commission.mjs';
4
+ import { createAffiliateSchema, createConversionSchema, createPayoutSchema, trackClickSchema, updateAffiliateSchema, updateCommissionSchema, updatePayoutSchema } from '../schemas.mjs';
5
+ import { getReferralClick, getAffiliate, getAffiliateByCode, serializeAffiliateRecord, getCommission, getPayout, getConversionByExternalRef, allRows } from './getters.mjs';
6
+
7
+ function assertAffiliateSnapshot(current, expected) {
8
+ if (expected && (current.id !== expected.id || current.userId !== expected.userId || current.status !== expected.status)) {
9
+ fail("Affiliate changed after authorization");
10
+ }
11
+ }
12
+ const queues = /* @__PURE__ */ new WeakMap();
13
+ function fail(message, status = 409) {
14
+ throw new OperationHttpError(status, message, "AFFILIATE_CONFLICT");
15
+ }
16
+ async function withAffiliateTransaction(adapter, affiliateId, run) {
17
+ const config = adapter.options?.adapterConfig;
18
+ if (!config?.transaction)
19
+ fail("Affiliate writes require an adapter with transactions enabled", 503);
20
+ let pending = queues.get(adapter);
21
+ if (!pending) {
22
+ pending = /* @__PURE__ */ new Map();
23
+ queues.set(adapter, pending);
24
+ }
25
+ const key = config.adapterId === "memory" ? "*" : affiliateId ?? "create";
26
+ const previous = pending.get(key) ?? Promise.resolve();
27
+ const operation = previous.catch(() => {
28
+ }).then(
29
+ () => adapter.transaction(async (tx) => {
30
+ if (affiliateId) {
31
+ const locked = await tx.update({
32
+ model: "affiliate",
33
+ where: [{ field: "id", value: affiliateId, operator: "eq" }],
34
+ update: { lockVersion: crypto.randomUUID() }
35
+ });
36
+ if (!locked) fail("Affiliate not found", 404);
37
+ }
38
+ return run(tx);
39
+ })
40
+ );
41
+ pending.set(key, operation);
42
+ try {
43
+ return await operation;
44
+ } finally {
45
+ if (pending.get(key) === operation) pending.delete(key);
46
+ }
47
+ }
48
+ async function ensureUniqueAffiliate(adapter, input, excludeId) {
49
+ for (const field of ["code", "email", "userId"]) {
50
+ const value = input[field];
51
+ if (!value) continue;
52
+ const existing = await adapter.findOne({
53
+ model: "affiliate",
54
+ where: [{ field, value, operator: "eq" }]
55
+ });
56
+ if (existing && existing.id !== excludeId)
57
+ fail(`Affiliate ${field} is already in use`);
58
+ }
59
+ }
60
+ async function createAffiliate(adapter, input) {
61
+ const data = createAffiliateSchema.parse(input);
62
+ return withAffiliateTransaction(adapter, null, async (tx) => {
63
+ const values = {
64
+ ...data,
65
+ email: data.email.toLowerCase(),
66
+ code: data.code ?? `partner-${crypto.randomUUID().replaceAll("-", "").slice(0, 16)}`
67
+ };
68
+ await ensureUniqueAffiliate(tx, values);
69
+ return serializeAffiliateRecord(
70
+ await tx.create({
71
+ model: "affiliate",
72
+ data: {
73
+ ...values,
74
+ status: "pending",
75
+ createdAt: /* @__PURE__ */ new Date(),
76
+ updatedAt: /* @__PURE__ */ new Date()
77
+ }
78
+ })
79
+ );
80
+ });
81
+ }
82
+ async function updateAffiliate(adapter, id, input, expected) {
83
+ const data = updateAffiliateSchema.parse(input);
84
+ return withAffiliateTransaction(adapter, id, async (tx) => {
85
+ const current = await getAffiliate(tx, id);
86
+ if (!current) fail("Affiliate not found", 404);
87
+ assertAffiliateSnapshot(current, expected);
88
+ const values = {
89
+ ...data,
90
+ ...data.email ? { email: data.email.toLowerCase() } : {}
91
+ };
92
+ await ensureUniqueAffiliate(tx, values, id);
93
+ if (data.status && data.status !== current.status) {
94
+ const allowed = current.status === "pending" ? ["approved", "suspended"] : current.status === "approved" ? ["suspended"] : ["approved"];
95
+ if (!allowed.includes(data.status))
96
+ fail("Invalid affiliate status transition");
97
+ }
98
+ const ruleType = data.commissionType === void 0 ? current.commissionType : data.commissionType;
99
+ const ruleValue = data.commissionValue === void 0 ? current.commissionValue : data.commissionValue;
100
+ if (ruleType == null !== (ruleValue == null))
101
+ fail("Commission override requires both type and value", 400);
102
+ if (ruleType != null && ruleValue != null) {
103
+ try {
104
+ calculateCommission(0, ruleType, ruleValue);
105
+ } catch (error) {
106
+ fail(
107
+ error instanceof Error ? error.message : "Invalid commission rule",
108
+ 400
109
+ );
110
+ }
111
+ }
112
+ return serializeAffiliateRecord(
113
+ await tx.update({
114
+ model: "affiliate",
115
+ where: [{ field: "id", value: id, operator: "eq" }],
116
+ update: { ...values, updatedAt: /* @__PURE__ */ new Date() }
117
+ })
118
+ );
119
+ });
120
+ }
121
+ async function deleteAffiliate(adapter, id, expected) {
122
+ return withAffiliateTransaction(adapter, id, async (tx) => {
123
+ const current = await getAffiliate(tx, id);
124
+ if (!current) fail("Affiliate not found", 404);
125
+ assertAffiliateSnapshot(current, expected);
126
+ for (const model of [
127
+ "affiliateConversion",
128
+ "affiliateCommission",
129
+ "affiliatePayout"
130
+ ]) {
131
+ if (await tx.count({
132
+ model,
133
+ where: [{ field: "affiliateId", value: id, operator: "eq" }]
134
+ }))
135
+ fail(
136
+ "Affiliates with financial history must be suspended instead of deleted"
137
+ );
138
+ }
139
+ await tx.deleteMany({
140
+ model: "referralClick",
141
+ where: [{ field: "affiliateId", value: id, operator: "eq" }]
142
+ });
143
+ await tx.delete({
144
+ model: "affiliate",
145
+ where: [{ field: "id", value: id, operator: "eq" }]
146
+ });
147
+ return { success: true };
148
+ });
149
+ }
150
+ async function trackClick(adapter, input) {
151
+ const data = trackClickSchema.parse(input);
152
+ const landingPath = safeLandingPath(data.landingPath);
153
+ const affiliate = await getAffiliateByCode(adapter, data.code);
154
+ if (!affiliate || affiliate.status !== "approved")
155
+ fail("Approved affiliate not found", 404);
156
+ return withAffiliateTransaction(adapter, affiliate.id, async (tx) => {
157
+ if ((await getAffiliate(tx, affiliate.id))?.status !== "approved")
158
+ fail("Approved affiliate not found", 404);
159
+ return serializeAffiliateRecord(
160
+ await tx.create({
161
+ model: "referralClick",
162
+ data: {
163
+ ...data,
164
+ landingPath,
165
+ affiliateId: affiliate.id,
166
+ createdAt: /* @__PURE__ */ new Date()
167
+ }
168
+ })
169
+ );
170
+ });
171
+ }
172
+ async function previousConversion(adapter, input) {
173
+ if (!input.externalRef) return null;
174
+ const conversion = await getConversionByExternalRef(
175
+ adapter,
176
+ input.externalRef
177
+ );
178
+ if (!conversion) return null;
179
+ if (conversion.amount !== input.amount || conversion.currency !== (input.currency ?? "USD"))
180
+ fail("Conversion external reference already has different values");
181
+ const commission = await adapter.findOne({
182
+ model: "affiliateCommission",
183
+ where: [{ field: "conversionId", value: conversion.id, operator: "eq" }]
184
+ });
185
+ if (!commission) fail("Conversion commission is missing");
186
+ return {
187
+ conversion,
188
+ commission: serializeAffiliateRecord(commission)
189
+ };
190
+ }
191
+ async function createConversion(adapter, input, options = {}) {
192
+ const data = createConversionSchema.parse(input);
193
+ const existing = await previousConversion(adapter, data);
194
+ if (existing) return existing;
195
+ if (!data.attribution) return null;
196
+ const click = data.attribution.clickId ? await getReferralClick(adapter, data.attribution.clickId) : null;
197
+ if (data.attribution.clickId && (!click || !isClickWithinWindow(click, options.attributionWindowDays)))
198
+ return null;
199
+ const affiliate = click ? await getAffiliate(adapter, click.affiliateId) : data.attribution.code ? await getAffiliateByCode(adapter, data.attribution.code) : null;
200
+ if (!affiliate || affiliate.status !== "approved") return null;
201
+ if (click && data.attribution.code && click.code !== data.attribution.code)
202
+ fail("Attribution code does not match referral click", 400);
203
+ try {
204
+ return await withAffiliateTransaction(adapter, affiliate.id, async (tx) => {
205
+ const duplicate = await previousConversion(tx, data);
206
+ if (duplicate) return duplicate;
207
+ const current = await getAffiliate(tx, affiliate.id);
208
+ if (!current || current.status !== "approved") return null;
209
+ const type = current.commissionType ?? options.defaultCommissionType ?? "percent";
210
+ const value = current.commissionValue ?? options.defaultCommissionValue ?? 20;
211
+ const amount = calculateCommission(data.amount, type, value);
212
+ const conversion = serializeAffiliateRecord(
213
+ await tx.create({
214
+ model: "affiliateConversion",
215
+ data: {
216
+ affiliateId: affiliate.id,
217
+ ...click ? { clickId: click.id } : {},
218
+ ...data.externalRef ? { externalRef: data.externalRef } : {},
219
+ amount: data.amount,
220
+ currency: data.currency,
221
+ status: "pending",
222
+ createdAt: /* @__PURE__ */ new Date()
223
+ }
224
+ })
225
+ );
226
+ const commission = serializeAffiliateRecord(
227
+ await tx.create({
228
+ model: "affiliateCommission",
229
+ data: {
230
+ affiliateId: affiliate.id,
231
+ conversionId: conversion.id,
232
+ amount,
233
+ currency: data.currency,
234
+ status: "pending",
235
+ payoutId: "",
236
+ createdAt: /* @__PURE__ */ new Date(),
237
+ updatedAt: /* @__PURE__ */ new Date()
238
+ }
239
+ })
240
+ );
241
+ return { conversion, commission };
242
+ });
243
+ } catch (error) {
244
+ const duplicate = await previousConversion(adapter, data);
245
+ if (duplicate) return duplicate;
246
+ throw error;
247
+ }
248
+ }
249
+ async function updateCommission(adapter, id, input, expected) {
250
+ const data = updateCommissionSchema.parse(input);
251
+ const found = await getCommission(adapter, id);
252
+ if (!found) fail("Commission not found", 404);
253
+ return withAffiliateTransaction(adapter, found.affiliateId, async (tx) => {
254
+ const current = await getCommission(tx, id);
255
+ if (!current) fail("Commission not found", 404);
256
+ if (expected && (current.id !== expected.id || current.affiliateId !== expected.affiliateId || current.status !== expected.status))
257
+ fail("Commission changed after authorization");
258
+ if (current.status === data.status) return current;
259
+ if (current.payoutId || !["pending", "approved"].includes(current.status) || current.status === "approved" && data.status !== "voided")
260
+ fail("Commission is paid, reserved, or cannot transition to that status");
261
+ const result = await tx.update({
262
+ model: "affiliateCommission",
263
+ where: [{ field: "id", value: id, operator: "eq" }],
264
+ update: { status: data.status, updatedAt: /* @__PURE__ */ new Date() }
265
+ });
266
+ await tx.update({
267
+ model: "affiliateConversion",
268
+ where: [{ field: "id", value: current.conversionId, operator: "eq" }],
269
+ update: { status: data.status === "approved" ? "approved" : "rejected" }
270
+ });
271
+ return serializeAffiliateRecord(result);
272
+ });
273
+ }
274
+ async function createPayout(adapter, input) {
275
+ const data = createPayoutSchema.parse(input);
276
+ return withAffiliateTransaction(adapter, data.affiliateId, async (tx) => {
277
+ const affiliate = await getAffiliate(tx, data.affiliateId);
278
+ if (!affiliate || affiliate.status !== "approved")
279
+ fail("Payouts require an approved affiliate");
280
+ const all = await allRows(tx, "affiliateCommission", [
281
+ { field: "affiliateId", value: data.affiliateId, operator: "eq" },
282
+ { field: "currency", value: data.currency, operator: "eq" },
283
+ { field: "status", value: "approved", operator: "eq" },
284
+ { field: "payoutId", value: "", operator: "eq" }
285
+ ]);
286
+ const selected = data.commissionIds ? all.filter((item) => data.commissionIds?.includes(item.id)) : all;
287
+ if (!selected.length || data.commissionIds && selected.length !== new Set(data.commissionIds).size)
288
+ fail("Select available approved commissions in one currency");
289
+ const amount = selected.reduce(
290
+ (total, item) => total + BigInt(item.amount),
291
+ 0n
292
+ );
293
+ if (amount > BigInt(Number.MAX_SAFE_INTEGER))
294
+ fail("Payout exceeds safe integer range", 400);
295
+ const payout = serializeAffiliateRecord(
296
+ await tx.create({
297
+ model: "affiliatePayout",
298
+ data: {
299
+ affiliateId: data.affiliateId,
300
+ amount: Number(amount),
301
+ currency: data.currency,
302
+ status: "pending",
303
+ createdAt: /* @__PURE__ */ new Date()
304
+ }
305
+ })
306
+ );
307
+ for (const item of selected) {
308
+ await tx.update({
309
+ model: "affiliateCommission",
310
+ where: [{ field: "id", value: item.id, operator: "eq" }],
311
+ update: { payoutId: payout.id, updatedAt: /* @__PURE__ */ new Date() }
312
+ });
313
+ }
314
+ return payout;
315
+ });
316
+ }
317
+ async function updatePayout(adapter, id, input, expected) {
318
+ const data = updatePayoutSchema.parse(input);
319
+ const found = await getPayout(adapter, id);
320
+ if (!found) fail("Payout not found", 404);
321
+ return withAffiliateTransaction(adapter, found.affiliateId, async (tx) => {
322
+ const current = await getPayout(tx, id);
323
+ if (!current) fail("Payout not found", 404);
324
+ if (expected && (current.id !== expected.id || current.affiliateId !== expected.affiliateId || current.status !== expected.status))
325
+ fail("Payout changed after authorization");
326
+ if (current.status === data.status) return current;
327
+ if (current.status !== "pending")
328
+ fail("Only pending payouts can be marked sent or failed");
329
+ const commissions = await allRows(tx, "affiliateCommission", [
330
+ { field: "payoutId", value: id, operator: "eq" }
331
+ ]);
332
+ for (const commission of commissions) {
333
+ await tx.update({
334
+ model: "affiliateCommission",
335
+ where: [{ field: "id", value: commission.id, operator: "eq" }],
336
+ update: data.status === "sent" ? { status: "paid", updatedAt: /* @__PURE__ */ new Date() } : { payoutId: "", updatedAt: /* @__PURE__ */ new Date() }
337
+ });
338
+ }
339
+ return serializeAffiliateRecord(
340
+ await tx.update({
341
+ model: "affiliatePayout",
342
+ where: [{ field: "id", value: id, operator: "eq" }],
343
+ update: {
344
+ ...data,
345
+ ...data.status === "sent" ? { paidAt: /* @__PURE__ */ new Date() } : {}
346
+ }
347
+ })
348
+ );
349
+ });
350
+ }
351
+
352
+ export { createAffiliate, createConversion, createPayout, deleteAffiliate, trackClick, updateAffiliate, updateCommission, updatePayout, withAffiliateTransaction };
@@ -0,0 +1,328 @@
1
+ 'use strict';
2
+
3
+ const api = require('@btst/stack/plugins/api');
4
+ const zod = require('zod');
5
+ const attribution = require('../attribution.cjs');
6
+ const permissions = require('../permissions.cjs');
7
+ const schemas = require('../schemas.cjs');
8
+ const getters = require('./getters.cjs');
9
+ const mutations = require('./mutations.cjs');
10
+
11
+ function fail(status, message) {
12
+ throw new api.OperationHttpError(status, message, "AFFILIATE_ERROR");
13
+ }
14
+ async function requireAffiliate(adapter, id) {
15
+ return await getters.getAffiliate(adapter, id) ?? fail(404, "Affiliate not found");
16
+ }
17
+ async function affiliateFacts(adapter, id) {
18
+ const affiliate = await requireAffiliate(adapter, id);
19
+ return {
20
+ affiliateId: affiliate.id,
21
+ userId: affiliate.userId,
22
+ status: affiliate.status
23
+ };
24
+ }
25
+ function requireIdentity(identity) {
26
+ return identity?.id ?? fail(401, "Sign in to manage your affiliate profile");
27
+ }
28
+ function attributionCookie(request) {
29
+ const value = request?.headers.get("cookie")?.split(";").map((entry) => entry.trim()).find((entry) => entry.startsWith(`${attribution.AFFILIATE_COOKIE_NAME}=`))?.slice(attribution.AFFILIATE_COOKIE_NAME.length + 1);
30
+ return value && /^[a-zA-Z0-9_-]{1,200}$/.test(value) ? value : void 0;
31
+ }
32
+ function createAffiliateOperations(adapter, options) {
33
+ return {
34
+ listAffiliates: api.defineOperation({
35
+ input: schemas.affiliateListSchema,
36
+ permission: permissions.affiliatePermissions.affiliate.read,
37
+ facts: () => ({ scope: "collection" }),
38
+ execute: async ({ input }) => {
39
+ const result = await getters.listAffiliates(adapter, input);
40
+ return {
41
+ ...result,
42
+ items: result.items.map((affiliate) => ({ ...affiliate }))
43
+ };
44
+ }
45
+ }),
46
+ getAffiliate: api.defineOperation({
47
+ input: schemas.idSchema,
48
+ permission: permissions.affiliatePermissions.affiliate.read,
49
+ facts: ({ input }) => ({
50
+ scope: "record",
51
+ affiliateId: input.id
52
+ }),
53
+ execute: async ({ input }) => ({
54
+ affiliate: { ...await requireAffiliate(adapter, input.id) },
55
+ stats: { ...await getters.getAffiliateStats(adapter, input.id) }
56
+ })
57
+ }),
58
+ createAffiliate: api.defineOperation({
59
+ input: schemas.createAffiliateSchema,
60
+ permission: permissions.affiliatePermissions.affiliate.create,
61
+ facts: () => void 0,
62
+ execute: async ({ input }) => ({
63
+ ...await mutations.createAffiliate(adapter, input)
64
+ })
65
+ }),
66
+ updateAffiliate: api.defineOperation({
67
+ input: schemas.idSchema.extend({ data: schemas.updateAffiliateSchema }),
68
+ permission: permissions.affiliatePermissions.affiliate.update,
69
+ facts: ({ input }) => affiliateFacts(adapter, input.id),
70
+ before: async (context) => {
71
+ if (context.input.data.status === "approved")
72
+ await options.hooks?.onBeforeApproveAffiliate?.(
73
+ await requireAffiliate(adapter, context.input.id),
74
+ context
75
+ );
76
+ },
77
+ execute: async ({ input, facts }) => ({
78
+ ...await mutations.updateAffiliate(adapter, input.id, input.data, {
79
+ id: facts.affiliateId,
80
+ userId: facts.userId,
81
+ status: facts.status
82
+ })
83
+ })
84
+ }),
85
+ deleteAffiliate: api.defineOperation({
86
+ input: schemas.idSchema,
87
+ permission: permissions.affiliatePermissions.affiliate.delete,
88
+ facts: ({ input }) => affiliateFacts(adapter, input.id),
89
+ execute: async ({ input, facts }) => {
90
+ await mutations.deleteAffiliate(adapter, input.id, {
91
+ id: facts.affiliateId,
92
+ userId: facts.userId,
93
+ status: facts.status
94
+ });
95
+ return { success: true };
96
+ }
97
+ }),
98
+ apply: api.defineOperation({
99
+ input: schemas.applicationSchema,
100
+ permission: permissions.affiliatePermissions.application.create,
101
+ facts: () => void 0,
102
+ execute: async ({ input, identity }) => ({
103
+ ...await mutations.createAffiliate(adapter, {
104
+ ...input,
105
+ userId: requireIdentity(identity)
106
+ })
107
+ })
108
+ }),
109
+ getPortal: api.defineOperation({
110
+ input: zod.z.object({}),
111
+ permission: permissions.affiliatePermissions.portal.read,
112
+ facts: () => void 0,
113
+ execute: async ({ identity }) => {
114
+ const portal = await getters.getPortal(
115
+ adapter,
116
+ requireIdentity(identity),
117
+ options.marketingAssets
118
+ );
119
+ return {
120
+ affiliate: portal.affiliate ? { ...portal.affiliate } : null,
121
+ stats: { ...portal.stats },
122
+ commissions: portal.commissions.map((commission) => ({
123
+ ...commission
124
+ })),
125
+ payouts: portal.payouts.map((payout) => ({ ...payout })),
126
+ assets: portal.assets.map((asset) => ({ ...asset }))
127
+ };
128
+ }
129
+ }),
130
+ updatePortal: api.defineOperation({
131
+ input: schemas.updatePortalSchema,
132
+ permission: permissions.affiliatePermissions.portal.update,
133
+ facts: () => void 0,
134
+ execute: async ({ input, identity }) => {
135
+ const affiliate = await getters.getAffiliateByUserId(
136
+ adapter,
137
+ requireIdentity(identity)
138
+ ) ?? fail(404, "Apply to the affiliate program first");
139
+ const updated = await mutations.updateAffiliate(
140
+ adapter,
141
+ affiliate.id,
142
+ input
143
+ );
144
+ const { notes: _notes, ...profile } = updated;
145
+ return profile;
146
+ }
147
+ }),
148
+ trackClick: api.definePassthroughOperation({
149
+ input: schemas.trackClickSchema,
150
+ permission: permissions.affiliatePermissions.tracking.track,
151
+ access: "public",
152
+ facts: () => void 0,
153
+ execute: async ({ input, request }) => {
154
+ let landingPath;
155
+ try {
156
+ landingPath = attribution.safeLandingPath(input.landingPath);
157
+ } catch {
158
+ fail(400, "Landing path must be a local absolute path");
159
+ }
160
+ const destination = new URL(
161
+ landingPath,
162
+ options.siteOrigin ?? request?.url ?? "https://affiliate.invalid"
163
+ );
164
+ const click = await mutations.trackClick(adapter, {
165
+ ...input,
166
+ landingPath,
167
+ referrer: request?.headers.get("referer")?.slice(0, 2048) ?? input.referrer,
168
+ userAgent: request?.headers.get("user-agent")?.slice(0, 2048) ?? input.userAgent
169
+ });
170
+ const previousId = attributionCookie(request);
171
+ const previous = previousId ? await getters.getReferralClick(adapter, previousId) : null;
172
+ const previousAffiliate = previous ? await getters.getAffiliate(adapter, previous.affiliateId) : null;
173
+ const selected = attribution.selectAttributionClick(
174
+ previousAffiliate?.status === "approved" ? previous : null,
175
+ click,
176
+ options
177
+ );
178
+ const headers = new Headers({
179
+ Location: options.siteOrigin ? destination.href : destination.pathname + destination.search + destination.hash,
180
+ "Cache-Control": "no-store, private",
181
+ "Referrer-Policy": "no-referrer"
182
+ });
183
+ if (selected) {
184
+ const maxAge = Math.max(
185
+ 0,
186
+ Math.floor(
187
+ (new Date(selected.createdAt).getTime() + (options.attributionWindowDays ?? 30) * 864e5 - Date.now()) / 1e3
188
+ )
189
+ );
190
+ headers.set(
191
+ "Set-Cookie",
192
+ `${attribution.AFFILIATE_COOKIE_NAME}=${selected.id}; Path=/; Max-Age=${maxAge}; HttpOnly; SameSite=Lax${request && new URL(request.url).protocol === "https:" ? "; Secure" : ""}`
193
+ );
194
+ }
195
+ return new Response(null, { status: 302, headers });
196
+ }
197
+ }),
198
+ createConversion: api.defineOperation({
199
+ input: schemas.createConversionSchema,
200
+ permission: permissions.affiliatePermissions.conversion.create,
201
+ facts: () => void 0,
202
+ execute: async ({ input, request }) => {
203
+ let requestAttribution = request ? attribution.attributionFromRequest(request) : void 0;
204
+ if (!input.attribution) {
205
+ const cookieId = attributionCookie(request);
206
+ const click = cookieId ? await getters.getReferralClick(adapter, cookieId) : null;
207
+ if (click && attribution.isClickWithinWindow(click, options.attributionWindowDays) && (await getters.getAffiliate(adapter, click.affiliateId))?.status === "approved")
208
+ requestAttribution = { clickId: click.id };
209
+ }
210
+ if (!input.attribution && !schemas.createConversionSchema.shape.attribution.safeParse(
211
+ requestAttribution
212
+ ).success)
213
+ fail(400, "Invalid referral attribution");
214
+ const result = await mutations.createConversion(
215
+ adapter,
216
+ {
217
+ ...input,
218
+ attribution: input.attribution ?? requestAttribution
219
+ },
220
+ options
221
+ );
222
+ return result ? {
223
+ conversion: { ...result.conversion },
224
+ commission: { ...result.commission }
225
+ } : null;
226
+ },
227
+ after: async (context) => {
228
+ if (context.result)
229
+ await options.hooks?.onAfterConversion?.(
230
+ context.result.conversion,
231
+ context.result.commission,
232
+ context
233
+ );
234
+ }
235
+ }),
236
+ listCommissions: api.defineOperation({
237
+ input: schemas.listSchema,
238
+ permission: permissions.affiliatePermissions.commission.read,
239
+ facts: () => void 0,
240
+ execute: async ({ input }) => {
241
+ const result = await getters.listCommissions(adapter, input);
242
+ return {
243
+ ...result,
244
+ items: result.items.map((commission) => ({ ...commission }))
245
+ };
246
+ }
247
+ }),
248
+ updateCommission: api.defineOperation({
249
+ input: schemas.idSchema.extend({ data: schemas.updateCommissionSchema }),
250
+ permission: permissions.affiliatePermissions.commission.update,
251
+ facts: async ({ input }) => {
252
+ const commission = await getters.getCommission(adapter, input.id) ?? fail(404, "Commission not found");
253
+ return {
254
+ commissionId: commission.id,
255
+ affiliateId: commission.affiliateId,
256
+ status: commission.status
257
+ };
258
+ },
259
+ execute: async ({ input, facts }) => ({
260
+ ...await mutations.updateCommission(adapter, input.id, input.data, {
261
+ id: facts.commissionId,
262
+ affiliateId: facts.affiliateId,
263
+ status: facts.status
264
+ })
265
+ })
266
+ }),
267
+ listPayouts: api.defineOperation({
268
+ input: schemas.listSchema,
269
+ permission: permissions.affiliatePermissions.payout.read,
270
+ facts: () => void 0,
271
+ execute: async ({ input }) => {
272
+ const result = await getters.listPayouts(adapter, input);
273
+ return {
274
+ ...result,
275
+ items: result.items.map((payout) => ({ ...payout }))
276
+ };
277
+ }
278
+ }),
279
+ createPayout: api.defineOperation({
280
+ input: schemas.createPayoutSchema,
281
+ permission: permissions.affiliatePermissions.payout.create,
282
+ facts: async ({ input }) => {
283
+ await requireAffiliate(adapter, input.affiliateId);
284
+ return { affiliateId: input.affiliateId, currency: input.currency };
285
+ },
286
+ before: (context) => options.hooks?.onBeforePayout?.(context.input, context),
287
+ execute: async ({ input }) => ({
288
+ ...await mutations.createPayout(adapter, {
289
+ ...input,
290
+ commissionIds: input.commissionIds ? [...input.commissionIds] : void 0
291
+ })
292
+ })
293
+ }),
294
+ updatePayout: api.defineOperation({
295
+ input: schemas.idSchema.extend({ data: schemas.updatePayoutSchema }),
296
+ permission: permissions.affiliatePermissions.payout.update,
297
+ facts: async ({ input }) => {
298
+ const payout = await getters.getPayout(adapter, input.id) ?? fail(404, "Payout not found");
299
+ return {
300
+ payoutId: payout.id,
301
+ affiliateId: payout.affiliateId,
302
+ status: payout.status
303
+ };
304
+ },
305
+ execute: async ({ input, facts }) => ({
306
+ ...await mutations.updatePayout(adapter, input.id, input.data, {
307
+ id: facts.payoutId,
308
+ affiliateId: facts.affiliateId,
309
+ status: facts.status
310
+ })
311
+ }),
312
+ after: async (context) => {
313
+ if (context.result.status === "sent")
314
+ await options.hooks?.onAfterPayout?.(context.result, context);
315
+ }
316
+ }),
317
+ exportPayouts: api.defineOperation({
318
+ input: schemas.listSchema,
319
+ permission: permissions.affiliatePermissions.payout.export,
320
+ facts: () => void 0,
321
+ execute: async ({ input }) => ({
322
+ csv: await getters.exportPayouts(adapter, input)
323
+ })
324
+ })
325
+ };
326
+ }
327
+
328
+ exports.createAffiliateOperations = createAffiliateOperations;