forked from votrongdao/FlowX
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCampaignCapabilities.cs
More file actions
331 lines (284 loc) · 12.2 KB
/
Copy pathCampaignCapabilities.cs
File metadata and controls
331 lines (284 loc) · 12.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
using FlowX;
namespace Crm;
/// <summary>
/// Declares a campaign.
/// </summary>
[Capability("crm.campaign.define", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.admin")]
public sealed class DefineCrmCampaign : ICapability<CampaignBy, CampaignDefined>
{
private readonly CampaignStore _campaigns;
/// <summary>Creates the capability.</summary>
/// <param name="campaigns">Writes the campaign.</param>
/// <exception cref="ArgumentNullException"><paramref name="campaigns"/> is null.</exception>
public DefineCrmCampaign(CampaignStore campaigns)
{
ArgumentNullException.ThrowIfNull(campaigns);
_campaigns = campaigns;
}
/// <inheritdoc />
public async ValueTask<Result<CampaignDefined>> ExecuteAsync(
CampaignBy input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (input.Define is not { } define)
{
return Result.Fail<CampaignDefined>(BulkErrors.AskForOneOrTheOther());
}
if (!CustomValues.IsUsableName(define.Name))
{
return Result.Fail<CampaignDefined>(CustomSchemaErrors.NameIsNotUsable(define.Name));
}
if (define.EndsOn < define.StartsOn)
{
return Result.Fail<CampaignDefined>(CampaignErrors.EndsBeforeItStarts());
}
return await _campaigns
.SaveCampaignAsync(ctx.TenantId, ctx.NewId(), define, ctx.UtcNow, ct)
.ConfigureAwait(false) is { } saved
? Result.Ok(new CampaignDefined(
saved, define.EndsOn.DayNumber - define.StartsOn.DayNumber + 1))
: Result.Fail<CampaignDefined>(CustomSchemaErrors.NameIsTaken(define.Name));
}
}
/// <summary>
/// Records that a campaign reached somebody.
/// </summary>
/// <remarks>
/// <strong>A replay is told it is a replay.</strong> Loading a batch twice is the ordinary case for
/// a marketing pipeline, and a caller that could not tell a replay from a new touch would either
/// double every open — the denominator of every rate in the report — or stop loading.
/// </remarks>
[Capability("crm.campaign.touch", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.write")]
public sealed class RecordCrmCampaignTouch : ICapability<CampaignBy, TouchRecorded>
{
private readonly CampaignStore _campaigns;
/// <summary>Creates the capability.</summary>
/// <param name="campaigns">Writes the touch.</param>
/// <exception cref="ArgumentNullException"><paramref name="campaigns"/> is null.</exception>
public RecordCrmCampaignTouch(CampaignStore campaigns)
{
ArgumentNullException.ThrowIfNull(campaigns);
_campaigns = campaigns;
}
/// <inheritdoc />
public async ValueTask<Result<TouchRecorded>> ExecuteAsync(
CampaignBy input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (input.Touch is not { } touch)
{
return Result.Fail<TouchRecorded>(BulkErrors.AskForOneOrTheOther());
}
// Neither is a row that can never be attributed to a deal; both is a row that would be
// counted twice, once down each path from the deal back to the person.
if (touch.LeadId is null == (touch.ContactId is null))
{
return Result.Fail<TouchRecorded>(CampaignErrors.TouchReachesOneOrTheOther());
}
return await _campaigns.SaveTouchAsync(ctx.TenantId, ctx.NewId(), touch, ct)
.ConfigureAwait(false) is { } saved
? Result.Ok(saved)
: Result.Fail<TouchRecorded>(CampaignErrors.PersonNotFound());
}
}
/// <summary>
/// Records what a campaign actually cost.
/// </summary>
/// <remarks>
/// <strong>Spend over budget is reported, not refused.</strong> The money has already gone. A
/// ledger that would not record it is one somebody keeps in a spreadsheet instead, and then the
/// return this build computes is a return on a number that was never true.
/// </remarks>
[Capability("crm.campaign.cost", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.admin")]
public sealed class RecordCrmCampaignCost : ICapability<CampaignBy, CampaignCostRecorded>
{
private readonly CampaignStore _campaigns;
/// <summary>Creates the capability.</summary>
/// <param name="campaigns">Writes the spend.</param>
/// <exception cref="ArgumentNullException"><paramref name="campaigns"/> is null.</exception>
public RecordCrmCampaignCost(CampaignStore campaigns)
{
ArgumentNullException.ThrowIfNull(campaigns);
_campaigns = campaigns;
}
/// <inheritdoc />
public async ValueTask<Result<CampaignCostRecorded>> ExecuteAsync(
CampaignBy input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (input.Cost is not { } cost)
{
return Result.Fail<CampaignCostRecorded>(BulkErrors.AskForOneOrTheOther());
}
if (cost.Amount <= 0)
{
return Result.Fail<CampaignCostRecorded>(
CampaignErrors.SpendIsNotPositive(cost.Amount));
}
return await _campaigns.SaveCostAsync(ctx.TenantId, cost, input.UserId, ctx.UtcNow, ct)
.ConfigureAwait(false) is { } saved
? Result.Ok(saved)
: Result.Fail<CampaignCostRecorded>(CampaignErrors.CampaignNotFound(cost.Campaign));
}
}
/// <summary>
/// How the campaigns did, under a model somebody named.
/// </summary>
/// <remarks>
/// <para>
/// <strong>The model is carried in the answer.</strong> Credit is the output of a model, not a
/// fact about a deal, and a number quoted without the model that produced it is one two
/// departments can argue about for a quarter while both are right.
/// </para>
/// <para>
/// <strong>What was attributed is reported next to what was considered.</strong> They will not
/// agree, because a deal nothing touched is not attributable — and a report that made them agree
/// would be inventing influence to close a gap.
/// </para>
/// </remarks>
[Capability("crm.campaign.performance", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class ReadCrmCampaignPerformance : ICapability<CampaignBy, CampaignReport>
{
private readonly CampaignStore _campaigns;
/// <summary>Creates the capability.</summary>
/// <param name="campaigns">Reads the campaigns, the touches and the deals.</param>
/// <exception cref="ArgumentNullException"><paramref name="campaigns"/> is null.</exception>
public ReadCrmCampaignPerformance(CampaignStore campaigns)
{
ArgumentNullException.ThrowIfNull(campaigns);
_campaigns = campaigns;
}
/// <inheritdoc />
public async ValueTask<Result<CampaignReport>> ExecuteAsync(
CampaignBy input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (input.Performance is not { } ask)
{
return Result.Fail<CampaignReport>(BulkErrors.AskForOneOrTheOther());
}
var from = ask.From is { } start
? new DateTimeOffset(start.ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)
: DateTimeOffset.MinValue;
// The day after the last one asked for. A window written as a closed interval on a
// timestamp drops everything decided after midnight on its final day, which is most of it.
var to = ask.To is { } end
? new DateTimeOffset(end.AddDays(1).ToDateTime(TimeOnly.MinValue), TimeSpan.Zero)
: DateTimeOffset.MaxValue;
var totals = await _campaigns.TotalsAsync(ctx.TenantId, ct).ConfigureAwait(false);
var deals = await _campaigns.DecidedAsync(ctx.TenantId, from, to, ct).ConfigureAwait(false);
var attributed = new Dictionary<Guid, decimal>();
var influenced = new Dictionary<Guid, int>();
var shared = 0m;
foreach (var deal in deals)
{
var credits = Attribution.Split(ask.Model, deal.Touches, deal.Amount, deal.DecidedAt);
foreach (var credit in credits)
{
attributed[credit.CampaignId] =
attributed.GetValueOrDefault(credit.CampaignId) + credit.Amount;
influenced[credit.CampaignId] = influenced.GetValueOrDefault(credit.CampaignId) + 1;
shared += credit.Amount;
}
}
var performance = totals
.Select(row => new CampaignPerformance(
row.CampaignId,
row.Name,
row.Label,
row.Channel,
row.People,
row.Responses,
// Null and not zero. A campaign that reached nobody has no rate, and reporting a
// zero sorts it below one that reached a thousand people and converted one.
row.People == 0 ? null : (double)row.Responses / row.People,
influenced.GetValueOrDefault(row.CampaignId),
attributed.GetValueOrDefault(row.CampaignId),
row.Budget,
row.Spent,
row.Responses == 0 ? null : decimal.Round(row.Spent / row.Responses, 4),
row.Spent == 0 ? null : (double)(attributed.GetValueOrDefault(row.CampaignId) / row.Spent)))
.OrderByDescending(row => row.AttributedAmount)
.ThenBy(row => row.Campaign, StringComparer.Ordinal)
.ToList();
return Result.Ok(new CampaignReport(
ask.Model.ToString(),
performance,
deals.Count,
deals.Sum(deal => deal.Amount),
shared));
}
}
/// <summary>
/// Who influenced one deal.
/// </summary>
/// <remarks>
/// <strong>Touches after the decision are counted and reported, not silently dropped.</strong> A
/// campaign whose entire claim on a deal is post-sale email is claiming credit for a decision
/// already taken, and the number of touches that fell outside the cutoff is how somebody notices.
/// </remarks>
[Capability("crm.campaign.attribution", Version = "1.0.0",
Authorization = Authorization.Permission, Permission = "crm.read",
Idempotent = true)]
public sealed class ReadCrmDealAttribution : ICapability<CampaignBy, DealAttribution>
{
private readonly CampaignStore _campaigns;
/// <summary>Creates the capability.</summary>
/// <param name="campaigns">Reads the deal and its touches.</param>
/// <exception cref="ArgumentNullException"><paramref name="campaigns"/> is null.</exception>
public ReadCrmDealAttribution(CampaignStore campaigns)
{
ArgumentNullException.ThrowIfNull(campaigns);
_campaigns = campaigns;
}
/// <inheritdoc />
public async ValueTask<Result<DealAttribution>> ExecuteAsync(
CampaignBy input,
CapabilityContext ctx,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(input);
ArgumentNullException.ThrowIfNull(ctx);
if (input.Deal is not { } ask)
{
return Result.Fail<DealAttribution>(BulkErrors.AskForOneOrTheOther());
}
if (await _campaigns.DealAsync(ctx.TenantId, ask.OpportunityId, ct).ConfigureAwait(false)
is not { } deal)
{
return Result.Fail<DealAttribution>(
CampaignErrors.DealNotFound(ask.OpportunityId));
}
// Attribution needs a cutoff and an open deal has none.
if (deal.Outcome is null)
{
return Result.Fail<DealAttribution>(
CampaignErrors.DealIsStillOpen(ask.OpportunityId));
}
var credits = Attribution.Split(ask.Model, deal.Touches, deal.Amount, deal.DecidedAt);
return Result.Ok(new DealAttribution(
deal.OpportunityId,
ask.Model.ToString(),
deal.Amount,
deal.DecidedAt,
credits,
deal.Touches.Count(touch => touch.TouchedAt > deal.DecidedAt)));
}
}