backend-manager 5.0.161 → 5.0.163
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/CHANGELOG.md +5 -0
- package/TODO-2.md +26 -0
- package/package.json +1 -1
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +14 -1
- package/src/manager/routes/marketing/campaign/post.js +9 -82
- package/src/manager/routes/marketing/campaign/put.js +8 -58
- package/src/manager/routes/marketing/campaign/utils.js +58 -0
- package/TODO-MARKETING.md +0 -56
- package/TODO-PAYMENT-v2.md +0 -74
package/CHANGELOG.md
CHANGED
|
@@ -14,6 +14,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|
|
14
14
|
- `Fixed` for any bug fixes.
|
|
15
15
|
- `Security` in case of vulnerabilities.
|
|
16
16
|
|
|
17
|
+
# [5.0.163] - 2026-03-18
|
|
18
|
+
### Changed
|
|
19
|
+
- Refactored campaign POST/PUT routes to generic field passthrough — schema-validated fields flow through automatically via shared `buildCampaignDoc()` utility, no manual field assignments needed
|
|
20
|
+
- Extracted `normalizeSendAt()` and `DOC_LEVEL_FIELDS` into `routes/marketing/campaign/utils.js`
|
|
21
|
+
|
|
17
22
|
# [5.0.161] - 2026-03-18
|
|
18
23
|
### Added
|
|
19
24
|
- Port conflict detection in `serve` command — checks and kills blocking processes before starting Firebase server
|
package/TODO-2.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
TODO
|
|
2
|
+
* pdate deps LIKE WF, PP, ETC
|
|
3
|
+
|
|
4
|
+
PAYMENT SYSTEM
|
|
5
|
+
* Ensure attribution is working (utm but also the AD ID shit like from adwords, tiktok ads, meta etc)
|
|
6
|
+
* ensure legacy plan RESOLVING is working
|
|
7
|
+
|
|
8
|
+
payments/reactivate
|
|
9
|
+
* takes a subscription id and reactivates a cancelled subscription. this can only be done if the subscription is still within its billing period, otherwise the user would have to create a new subscription.
|
|
10
|
+
payments/upgrade
|
|
11
|
+
* takes a subscription id and a new plan id and upgrades the user's subscription to the new plan. this can only be done if the user has an active subscription.
|
|
12
|
+
|
|
13
|
+
TEST NEWSLETTER
|
|
14
|
+
POST /admin/cron { id: 'daily/marketing-newsletter-generate' }
|
|
15
|
+
|
|
16
|
+
Payment attribution
|
|
17
|
+
* can you ensure the the user's attribution (utm etc) is assocaited with the purchase
|
|
18
|
+
* mostly i am referring to the payent events sent to GA4, tiktok, meta
|
|
19
|
+
* i think we should make it so if the attribution was set less than 30 days ago (the date is in the attribution) then it counts
|
|
20
|
+
* it shouldbe attached to the payments-orders and then ALL FUTURE EVENTS should send that (thats a good idea right??)
|
|
21
|
+
|
|
22
|
+
Payment disputes
|
|
23
|
+
* automatic dispute handling
|
|
24
|
+
* during bm_cron daily, we should check for open disputes for pypal stripe etc... we should then FILL THEM WITH INFORMATION USEFUL FOR WINNING
|
|
25
|
+
* we should attach USAGE LOGS, IP logs, etc. you should take inspiration from my previous attmept at this (/Users/ian/Developer/Repositories/ITW-Creative-Works/subscription-profile-sync/main/disputes)
|
|
26
|
+
* HOWEVER, i know for a fact i got a lot of it wrong. so JUST USE AS INSPO, you should recreate it full
|
package/package.json
CHANGED
package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js
CHANGED
|
@@ -8,7 +8,12 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
|
|
|
8
8
|
const brandName = assistant.Manager.config.brand?.name || '';
|
|
9
9
|
const productName = after.product?.name || '';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
// Pre-compute discount values for the email template
|
|
12
|
+
const price = parseFloat(after.payment?.price || 0);
|
|
13
|
+
const discount = order.discount;
|
|
14
|
+
const hasPromoDiscount = discount?.valid === true && discount?.percent > 0;
|
|
15
|
+
|
|
16
|
+
assistant.log(`Transition [one-time/purchase-completed]: uid=${uid}, resourceId=${after.payment?.resourceId}, discount=${hasPromoDiscount ? discount.code : 'none'}`);
|
|
12
17
|
|
|
13
18
|
sendOrderEmail({
|
|
14
19
|
template: 'main/order/confirmation',
|
|
@@ -21,6 +26,14 @@ module.exports = async function ({ before, after, order, uid, userDoc, assistant
|
|
|
21
26
|
...order,
|
|
22
27
|
_computed: {
|
|
23
28
|
date: formatDate(new Date().toISOString()),
|
|
29
|
+
...(hasPromoDiscount && {
|
|
30
|
+
promoCode: discount.code,
|
|
31
|
+
promoPercent: discount.percent,
|
|
32
|
+
promoSavings: (price * discount.percent / 100).toFixed(2),
|
|
33
|
+
}),
|
|
34
|
+
totalToday: hasPromoDiscount
|
|
35
|
+
? (price - (price * discount.percent / 100)).toFixed(2)
|
|
36
|
+
: price.toFixed(2),
|
|
24
37
|
},
|
|
25
38
|
},
|
|
26
39
|
},
|
|
@@ -9,9 +9,8 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Content is markdown — converted to HTML at send time per provider.
|
|
11
11
|
*/
|
|
12
|
-
const _ = require('lodash');
|
|
13
|
-
const moment = require('moment');
|
|
14
12
|
const pushid = require('pushid');
|
|
13
|
+
const { buildCampaignDoc } = require('./utils');
|
|
15
14
|
|
|
16
15
|
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
17
16
|
|
|
@@ -24,55 +23,14 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
24
23
|
|
|
25
24
|
const { admin } = Manager.libraries;
|
|
26
25
|
const campaignId = settings.id || pushid();
|
|
27
|
-
const now =
|
|
26
|
+
const { docFields, campaignSettings, now } = buildCampaignDoc(settings);
|
|
28
27
|
|
|
29
|
-
|
|
30
|
-
const sendAt = normalizeSendAt(settings.sendAt, now);
|
|
31
|
-
|
|
32
|
-
// Build the campaign document (settings nested, like emails-queue)
|
|
33
|
-
const campaignSettings = {};
|
|
34
|
-
|
|
35
|
-
// Required
|
|
36
|
-
campaignSettings.name = settings.name;
|
|
37
|
-
campaignSettings.subject = settings.subject;
|
|
38
|
-
|
|
39
|
-
// Content
|
|
40
|
-
if (settings.preheader) { campaignSettings.preheader = settings.preheader; }
|
|
41
|
-
if (settings.template && settings.template !== 'default') { campaignSettings.template = settings.template; }
|
|
42
|
-
if (settings.content) { campaignSettings.content = settings.content; }
|
|
43
|
-
if (settings.data && Object.keys(settings.data).length) { campaignSettings.data = settings.data; }
|
|
44
|
-
|
|
45
|
-
// Targeting
|
|
46
|
-
if (settings.lists && settings.lists.length) { campaignSettings.lists = settings.lists; }
|
|
47
|
-
if (settings.segments && settings.segments.length) { campaignSettings.segments = settings.segments; }
|
|
48
|
-
if (settings.excludeSegments && settings.excludeSegments.length) { campaignSettings.excludeSegments = settings.excludeSegments; }
|
|
49
|
-
if (settings.all) { campaignSettings.all = true; }
|
|
50
|
-
|
|
51
|
-
// UTM
|
|
52
|
-
if (settings.utm && Object.keys(settings.utm).length) { campaignSettings.utm = settings.utm; }
|
|
53
|
-
|
|
54
|
-
// Config
|
|
55
|
-
if (settings.sender) { campaignSettings.sender = settings.sender; }
|
|
56
|
-
if (settings.providers && settings.providers.length) { campaignSettings.providers = settings.providers; }
|
|
57
|
-
if (settings.group) { campaignSettings.group = settings.group; }
|
|
58
|
-
if (settings.categories && settings.categories.length) { campaignSettings.categories = settings.categories; }
|
|
59
|
-
|
|
60
|
-
// Clone and clean undefined values for Firestore
|
|
61
|
-
const settingsCloned = _.cloneDeepWith(campaignSettings, (value) => {
|
|
62
|
-
if (typeof value === 'undefined') {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
const isFuture = sendAt > now.unix();
|
|
68
|
-
const type = settings.type || 'email';
|
|
28
|
+
const isFuture = docFields.sendAt > now.unix();
|
|
69
29
|
|
|
70
30
|
const doc = {
|
|
71
|
-
|
|
72
|
-
|
|
31
|
+
...docFields,
|
|
32
|
+
settings: campaignSettings,
|
|
73
33
|
status: 'pending',
|
|
74
|
-
type,
|
|
75
|
-
...(settings.recurrence ? { recurrence: settings.recurrence } : {}),
|
|
76
34
|
metadata: {
|
|
77
35
|
created: {
|
|
78
36
|
timestamp: now.toISOString(),
|
|
@@ -88,12 +46,12 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
88
46
|
// Save to Firestore
|
|
89
47
|
await admin.firestore().doc(`marketing-campaigns/${campaignId}`).set(doc);
|
|
90
48
|
|
|
91
|
-
assistant.log('marketing/campaign created:', { campaignId, sendAt, isFuture, type });
|
|
49
|
+
assistant.log('marketing/campaign created:', { campaignId, sendAt: docFields.sendAt, isFuture, type: docFields.type });
|
|
92
50
|
|
|
93
51
|
// If sendAt is now/past, fire immediately
|
|
94
52
|
let results = null;
|
|
95
53
|
|
|
96
|
-
if (!isFuture && type === 'email') {
|
|
54
|
+
if (!isFuture && docFields.type === 'email') {
|
|
97
55
|
const mailer = Manager.Email(assistant);
|
|
98
56
|
results = await mailer.sendCampaign({ ...campaignSettings, sendAt: 'now' });
|
|
99
57
|
|
|
@@ -117,45 +75,14 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
117
75
|
// Analytics
|
|
118
76
|
analytics.event('marketing/campaign', {
|
|
119
77
|
action: isFuture ? 'schedule' : 'send',
|
|
120
|
-
type,
|
|
78
|
+
type: docFields.type,
|
|
121
79
|
});
|
|
122
80
|
|
|
123
81
|
return assistant.respond({
|
|
124
82
|
success: true,
|
|
125
83
|
id: campaignId,
|
|
126
84
|
status: isFuture ? 'pending' : (results ? 'sent' : 'pending'),
|
|
127
|
-
sendAt,
|
|
85
|
+
sendAt: docFields.sendAt,
|
|
128
86
|
providers: results,
|
|
129
87
|
});
|
|
130
88
|
};
|
|
131
|
-
|
|
132
|
-
/**
|
|
133
|
-
* Normalize sendAt to unix timestamp.
|
|
134
|
-
* Accepts: 'now', ISO string, unix timestamp (number or string), undefined/empty.
|
|
135
|
-
* Defaults to now.
|
|
136
|
-
*/
|
|
137
|
-
function normalizeSendAt(sendAt, now) {
|
|
138
|
-
if (!sendAt || sendAt === 'now') {
|
|
139
|
-
return now.unix();
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// Unix timestamp (number)
|
|
143
|
-
if (typeof sendAt === 'number') {
|
|
144
|
-
return sendAt;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Unix timestamp as string (all digits)
|
|
148
|
-
if (/^\d+$/.test(sendAt)) {
|
|
149
|
-
return parseInt(sendAt, 10);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// ISO string or other parseable date
|
|
153
|
-
const parsed = moment(sendAt);
|
|
154
|
-
|
|
155
|
-
if (parsed.isValid()) {
|
|
156
|
-
return parsed.unix();
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
// Fallback to now
|
|
160
|
-
return now.unix();
|
|
161
|
-
}
|
|
@@ -5,8 +5,7 @@
|
|
|
5
5
|
* Accepts any field from the POST schema. Only provided fields are updated.
|
|
6
6
|
* Changing sendAt reschedules the campaign (if still pending).
|
|
7
7
|
*/
|
|
8
|
-
const
|
|
9
|
-
const moment = require('moment');
|
|
8
|
+
const { buildCampaignDoc } = require('./utils');
|
|
10
9
|
|
|
11
10
|
module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
12
11
|
|
|
@@ -39,8 +38,11 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
39
38
|
return assistant.respond(`Cannot edit campaign with status "${existing.status}"`, { code: 400 });
|
|
40
39
|
}
|
|
41
40
|
|
|
42
|
-
// Build update
|
|
41
|
+
// Build update from provided fields using shared utility
|
|
42
|
+
const { docFields, campaignSettings } = buildCampaignDoc(settings);
|
|
43
|
+
|
|
43
44
|
const update = {
|
|
45
|
+
...docFields,
|
|
44
46
|
metadata: {
|
|
45
47
|
updated: {
|
|
46
48
|
timestamp: new Date().toISOString(),
|
|
@@ -49,44 +51,9 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
49
51
|
},
|
|
50
52
|
};
|
|
51
53
|
|
|
52
|
-
//
|
|
53
|
-
if (
|
|
54
|
-
update.
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Update type if provided
|
|
58
|
-
if (settings.type) {
|
|
59
|
-
update.type = settings.type;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Update recurrence if provided
|
|
63
|
-
if (settings.recurrence !== undefined) {
|
|
64
|
-
update.recurrence = settings.recurrence;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Update settings fields — only merge what's provided
|
|
68
|
-
const settingsUpdate = {};
|
|
69
|
-
const settingsFields = [
|
|
70
|
-
'name', 'subject', 'preheader', 'template', 'content', 'data',
|
|
71
|
-
'lists', 'segments', 'excludeSegments', 'all',
|
|
72
|
-
'utm', 'sender', 'providers', 'group', 'categories',
|
|
73
|
-
];
|
|
74
|
-
|
|
75
|
-
for (const field of settingsFields) {
|
|
76
|
-
if (settings[field] !== undefined && settings[field] !== '') {
|
|
77
|
-
settingsUpdate[field] = settings[field];
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
if (Object.keys(settingsUpdate).length) {
|
|
82
|
-
// Clean undefined values for Firestore
|
|
83
|
-
const cleaned = _.cloneDeepWith(settingsUpdate, (value) => {
|
|
84
|
-
if (typeof value === 'undefined') {
|
|
85
|
-
return null;
|
|
86
|
-
}
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
update.settings = { ...existing.settings, ...cleaned };
|
|
54
|
+
// Merge provided settings into existing
|
|
55
|
+
if (Object.keys(campaignSettings).length) {
|
|
56
|
+
update.settings = { ...existing.settings, ...campaignSettings };
|
|
90
57
|
}
|
|
91
58
|
|
|
92
59
|
await docRef.set(update, { merge: true });
|
|
@@ -103,20 +70,3 @@ module.exports = async ({ assistant, user, Manager, settings, analytics }) => {
|
|
|
103
70
|
campaign: { id: campaignId, ...updated.data() },
|
|
104
71
|
});
|
|
105
72
|
};
|
|
106
|
-
|
|
107
|
-
function normalizeSendAt(sendAt) {
|
|
108
|
-
if (!sendAt || sendAt === 'now') {
|
|
109
|
-
return Math.round(Date.now() / 1000);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
if (typeof sendAt === 'number') {
|
|
113
|
-
return sendAt;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
if (/^\d+$/.test(sendAt)) {
|
|
117
|
-
return parseInt(sendAt, 10);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
const parsed = moment(sendAt);
|
|
121
|
-
return parsed.isValid() ? parsed.unix() : Math.round(Date.now() / 1000);
|
|
122
|
-
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared utilities for marketing campaign routes.
|
|
3
|
+
*/
|
|
4
|
+
const _ = require('lodash');
|
|
5
|
+
const moment = require('moment');
|
|
6
|
+
|
|
7
|
+
// Fields that live at the doc level, not inside doc.settings
|
|
8
|
+
const DOC_LEVEL_FIELDS = ['id', 'sendAt', 'type', 'recurrence', 'generator'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Separate settings into doc-level fields and nested settings.
|
|
12
|
+
* Returns { docFields, campaignSettings }.
|
|
13
|
+
*/
|
|
14
|
+
function buildCampaignDoc(settings) {
|
|
15
|
+
const now = moment();
|
|
16
|
+
|
|
17
|
+
// Extract doc-level fields
|
|
18
|
+
const sendAt = normalizeSendAt(settings.sendAt, now);
|
|
19
|
+
const type = settings.type || 'email';
|
|
20
|
+
|
|
21
|
+
const docFields = {
|
|
22
|
+
sendAt,
|
|
23
|
+
type,
|
|
24
|
+
...(settings.recurrence ? { recurrence: settings.recurrence } : {}),
|
|
25
|
+
...(settings.generator ? { generator: settings.generator } : {}),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
// Everything else goes into doc.settings — strip empties
|
|
29
|
+
const campaignSettings = _.omitBy(
|
|
30
|
+
_.omit(settings, DOC_LEVEL_FIELDS),
|
|
31
|
+
(v) => v === undefined || v === '' || (Array.isArray(v) && !v.length) || (_.isPlainObject(v) && !Object.keys(v).length),
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
return { docFields, campaignSettings, now };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize sendAt to unix timestamp.
|
|
39
|
+
* Accepts: 'now', ISO string, unix timestamp (number or string), undefined/empty.
|
|
40
|
+
*/
|
|
41
|
+
function normalizeSendAt(sendAt, now) {
|
|
42
|
+
if (!sendAt || sendAt === 'now') {
|
|
43
|
+
return (now || moment()).unix();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (typeof sendAt === 'number') {
|
|
47
|
+
return sendAt;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (/^\d+$/.test(sendAt)) {
|
|
51
|
+
return parseInt(sendAt, 10);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const parsed = moment(sendAt);
|
|
55
|
+
return parsed.isValid() ? parsed.unix() : (now || moment()).unix();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
module.exports = { DOC_LEVEL_FIELDS, buildCampaignDoc, normalizeSendAt };
|
package/TODO-MARKETING.md
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
1. Determine name via AI on signup
|
|
2
|
-
2. Add to sendgrid marketig cotacts
|
|
3
|
-
3. then, develop a way to SYNC them to the marketing contacts. we coould sync on payment changes (liek newly subscribed, cancelled, etc) so we can segment them??
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
When user sings up, use AI to generate first name, last name, company???
|
|
7
|
-
When user doc is updated, sync with sendgrid and beehiiv?? like name, premium status, etc
|
|
8
|
-
if admin is set to true from something else then we send an emergency critical email to alert us
|
|
9
|
-
|
|
10
|
-
need to confirm we hv hooks/events setup properly
|
|
11
|
-
handlerPath = `${Manager.cwd}/events/${handlerName}.js`;
|
|
12
|
-
liek bm_cronDaily, is it able to find the BEM path right? what about if we want to add our own per project?
|
|
13
|
-
|
|
14
|
-
implement BEM hooks (removed in muddleware semantic system)
|
|
15
|
-
|
|
16
|
-
https://firebase.google.com/docs/functions/2nd-gen-upgrade
|
|
17
|
-
|
|
18
|
-
------------
|
|
19
|
-
You are to extract the first name, last name, and company from the provided email.
|
|
20
|
-
|
|
21
|
-
If you can get the company from the email domain, include that as well but DO NOT set the company to generic email providers like gmail, yahoo, etc.
|
|
22
|
-
|
|
23
|
-
You may use a single initial if the email does not provide a full first name.
|
|
24
|
-
|
|
25
|
-
For example:
|
|
26
|
-
jonsnow123@gmail.com, jon.snow123@gmail.com
|
|
27
|
-
First Name: Jon
|
|
28
|
-
Last Name: Snow
|
|
29
|
-
Company:
|
|
30
|
-
|
|
31
|
-
jsnow123@gmail.com, j.snow123@gmail.com
|
|
32
|
-
First Name: J
|
|
33
|
-
Last Name: Snow
|
|
34
|
-
Company:
|
|
35
|
-
|
|
36
|
-
jon.snow@acme.com
|
|
37
|
-
First Name: Jon
|
|
38
|
-
Last Name: Snow
|
|
39
|
-
Company: Acme
|
|
40
|
-
|
|
41
|
-
jsnow@acme.com
|
|
42
|
-
First Name: J
|
|
43
|
-
Last Name: Snow
|
|
44
|
-
Company: Acme
|
|
45
|
-
|
|
46
|
-
jon123@gmail.com
|
|
47
|
-
First Name: Jon
|
|
48
|
-
Last Name:
|
|
49
|
-
Company:
|
|
50
|
-
|
|
51
|
-
every time we touch, cascada
|
|
52
|
-
just dance, lady gaga
|
|
53
|
-
over drake,
|
|
54
|
-
Time, hans zimmer
|
|
55
|
-
yellow, coldplay
|
|
56
|
-
yess bitch,
|
package/TODO-PAYMENT-v2.md
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
TODO payments
|
|
2
|
-
|
|
3
|
-
would it be beneficial to... check the time on the event and the time in our datbase and skip if the time in oour database is nwer than the vent, indicatig that it is stale? or is this redundatn since we fetch the latest resource anyway?
|
|
4
|
-
|
|
5
|
-
next, we need to do certain things based on the CHANGE thats happening to the resource (subscription or one time) that is different that simply updating the user doc and the payments-subscriptions or payments-one-time collection. For example:
|
|
6
|
-
* if a new subscription is created, we send a welcome email and grant access to the product (but this cant happen when a user fixes their payment method from a failed payment, only when a new subscription is created)
|
|
7
|
-
* if a subscription is cancelled, we send a cancellation email
|
|
8
|
-
* if a subscription payment fails, we send a paymetn failed "please update your payment method" email
|
|
9
|
-
* if a subscription payment succeeds after previously failing, we send a "payment successful, your access has been restored" email
|
|
10
|
-
|
|
11
|
-
more endpoints to build
|
|
12
|
-
payments/cancel
|
|
13
|
-
* takes a subscrition id and requests to cancel at the end of the billing period (not immediately). this can only be done if the user has an non cancelled subscription.
|
|
14
|
-
* there should be an accompanying frontend form that asks some outboardig quetsions
|
|
15
|
-
* Why are you cancelling (checkboxes + textbox) with randomzed order of the checkboes
|
|
16
|
-
* after doing this, users should still be able to reactivate it
|
|
17
|
-
payments/manage
|
|
18
|
-
* fetches the customer portal link from the payment processor and redirects the user there
|
|
19
|
-
* acessible from the user's account page in biling setion
|
|
20
|
-
* only accessible if the user has a non cancelled subscription
|
|
21
|
-
|
|
22
|
-
Note: * for managing and cancelling, they should be under a sinlge button/dropdown called "manage subscription".
|
|
23
|
-
* it could have links to the 2 pages as items in the dropdown, or we could take them to a dedicated page (or expand an accordian) that has more information about managing it
|
|
24
|
-
* we need to make it hard to cancel, buried in some info, and make it more prominent to manage it, since we want to encourage users to manage their subscription rather than cancelling it.
|
|
25
|
-
|
|
26
|
-
payments/refund
|
|
27
|
-
* takes a subscription id and requests a refund to the payment processor
|
|
28
|
-
* we can only refund the most recent payment and we can only refund if the subscription is cancelled.
|
|
29
|
-
* we can only refund if the most recent payment in FULL was made less than 7 days ago, otherwise we can only refund a prorated amount based on how much time is left
|
|
30
|
-
* i want the subscription to be immediately revoked upon refund request
|
|
31
|
-
* generally, we dont modify the subscription DURING the http endpoint (such as payment intent, or cancel), rather we WAIT FOR THE WEBHOOOK. Can we do that here???
|
|
32
|
-
|
|
33
|
-
payments/reactivate
|
|
34
|
-
* takes a subscription id and reactivates a cancelled subscription. this can only be done if the subscription is still within its billing period, otherwise the user would have to create a new subscription.
|
|
35
|
-
payments/upgrade
|
|
36
|
-
* takes a subscription id and a new plan id and upgrades the user's subscription to the new plan. this can only be done if the user has an active subscription.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
trial support:
|
|
40
|
-
when a peyment intent happens, we can only grant a trial if the user has never had a trial which involves checking the paymetns-subscriptions collection for the user to see if any exist. if any exist in any form, then we dont give a trial.
|
|
41
|
-
we also need a test for this
|
|
42
|
-
|
|
43
|
-
block multipl epayments
|
|
44
|
-
check if user has a non-cancelled sub during payment intent creation, if so, block the payment intent from being created. this will prevent multiple payments from being made at the same time and causing issues. we can check the payments-subscriptions collection for ALL usbcriptions belonging to the UID. we can use this for the trial check as well (recycle the results).
|
|
45
|
-
we also need a test for this
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
also, can you check and confirm if the usage.js sends back the users current usage in th headers? i think it does? maybe it our tests we can check to ensure that the user is probably having their usage set this way?
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
MANAGEMENT LINK
|
|
53
|
-
we need an endpoint that returns a management link for the user to manage their subscription. this will involve us looking up the users current subscription, then calling the appropriate method on the payment processor to get a management link. we can only return a management link if the user has an active subscription.
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
TODO
|
|
57
|
-
|
|
58
|
-
* authorizations ystem that tries to charge the card to see if its valid?
|
|
59
|
-
* bm_cronDaily task that doublechecks subscriptions??
|
|
60
|
-
* could check existing ones eveyr month to ensure they are still vlaud and not messed up from failed webhook processing or could check every day to ensure theres no users with multiple subscirptions active simultaneously?
|
|
61
|
-
* bm_cronDaily yto handle disputes?
|
|
62
|
-
* automatically provide evidence?
|
|
63
|
-
|
|
64
|
-
ATTRIBUTIONS!!!
|
|
65
|
-
// Filter attribution entries older than 30 days
|
|
66
|
-
const attribution = webManager.storage().get('attribution', {});
|
|
67
|
-
const maxAge = 30 * 24 * 60 * 60 * 1000; // 30 days in ms
|
|
68
|
-
const filtered = {};
|
|
69
|
-
|
|
70
|
-
for (const [key, entry] of Object.entries(attribution)) {
|
|
71
|
-
if (!entry?.timestamp) continue;
|
|
72
|
-
if ((Date.now() - new Date(entry.timestamp).getTime()) < maxAge) {
|
|
73
|
-
filtered[key] = entry;
|
|
74
|
-
}
|