payment-kit 1.15.16 → 1.15.18
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/api/src/integrations/stripe/handlers/invoice.ts +20 -0
- package/api/src/integrations/stripe/resource.ts +2 -2
- package/api/src/libs/audit.ts +1 -1
- package/api/src/libs/invoice.ts +81 -1
- package/api/src/libs/notification/template/billing-discrepancy.ts +223 -0
- package/api/src/libs/notification/template/subscription-canceled.ts +11 -0
- package/api/src/libs/notification/template/subscription-refund-succeeded.ts +10 -2
- package/api/src/libs/notification/template/subscription-renew-failed.ts +10 -2
- package/api/src/libs/notification/template/subscription-renewed.ts +11 -3
- package/api/src/libs/notification/template/subscription-stake-slash-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-succeeded.ts +11 -1
- package/api/src/libs/notification/template/subscription-trial-start.ts +11 -0
- package/api/src/libs/notification/template/subscription-trial-will-end.ts +17 -0
- package/api/src/libs/notification/template/subscription-upgraded.ts +51 -26
- package/api/src/libs/notification/template/subscription-will-canceled.ts +16 -0
- package/api/src/libs/notification/template/subscription-will-renew.ts +15 -3
- package/api/src/libs/notification/template/usage-report-empty.ts +158 -0
- package/api/src/libs/queue/index.ts +69 -19
- package/api/src/libs/queue/store.ts +28 -5
- package/api/src/libs/subscription.ts +129 -19
- package/api/src/libs/util.ts +30 -0
- package/api/src/locales/en.ts +13 -0
- package/api/src/locales/zh.ts +13 -0
- package/api/src/queues/invoice.ts +58 -20
- package/api/src/queues/notification.ts +43 -1
- package/api/src/queues/payment.ts +5 -1
- package/api/src/queues/subscription.ts +64 -15
- package/api/src/routes/checkout-sessions.ts +26 -0
- package/api/src/routes/invoices.ts +11 -31
- package/api/src/routes/subscriptions.ts +43 -7
- package/api/src/store/models/checkout-session.ts +2 -0
- package/api/src/store/models/job.ts +4 -0
- package/api/src/store/models/types.ts +22 -4
- package/api/src/store/models/usage-record.ts +5 -1
- package/api/tests/libs/subscription.spec.ts +154 -0
- package/api/tests/libs/util.spec.ts +135 -0
- package/blocklet.yml +1 -1
- package/package.json +10 -10
- package/scripts/sdk.js +37 -3
- package/src/components/invoice/list.tsx +0 -1
- package/src/components/invoice/table.tsx +7 -2
- package/src/components/subscription/items/index.tsx +26 -7
- package/src/components/subscription/items/usage-records.tsx +21 -10
- package/src/components/subscription/portal/actions.tsx +16 -14
- package/src/libs/util.ts +51 -0
- package/src/locales/en.tsx +2 -0
- package/src/locales/zh.tsx +2 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +1 -1
- package/src/pages/customer/subscription/change-plan.tsx +1 -1
- package/src/pages/customer/subscription/embed.tsx +16 -14
- package/vite-server.config.ts +8 -0
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { LiteralUnion } from 'type-fest';
|
|
2
2
|
|
|
3
|
+
import { createEvent } from '@api/libs/audit';
|
|
3
4
|
import { ensurePassportRevoked } from '../integrations/blocklet/passport';
|
|
4
5
|
import { batchHandleStripeSubscriptions } from '../integrations/stripe/resource';
|
|
5
6
|
import { wallet } from '../libs/auth';
|
|
@@ -12,6 +13,7 @@ import createQueue from '../libs/queue';
|
|
|
12
13
|
import { getStatementDescriptor } from '../libs/session';
|
|
13
14
|
import {
|
|
14
15
|
checkRemainingStake,
|
|
16
|
+
checkUsageReportEmpty,
|
|
15
17
|
getSubscriptionCycleAmount,
|
|
16
18
|
getSubscriptionCycleSetup,
|
|
17
19
|
getSubscriptionStakeAddress,
|
|
@@ -111,6 +113,23 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
111
113
|
{ product: true }
|
|
112
114
|
);
|
|
113
115
|
|
|
116
|
+
const usageReportStart = usageStart || start - offset;
|
|
117
|
+
const usageReportEnd = usageEnd || end - offset;
|
|
118
|
+
|
|
119
|
+
// check if usage report is empty
|
|
120
|
+
const usageReportEmpty = await checkUsageReportEmpty(subscription, usageReportStart, usageReportEnd);
|
|
121
|
+
if (usageReportEmpty) {
|
|
122
|
+
createEvent('Subscription', 'usage.report.empty', subscription, {
|
|
123
|
+
usageReportStart,
|
|
124
|
+
usageReportEnd,
|
|
125
|
+
}).catch(console.error);
|
|
126
|
+
logger.info('create usage report empty event', {
|
|
127
|
+
subscriptionId: subscription.id,
|
|
128
|
+
usageReportStart,
|
|
129
|
+
usageReportEnd,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
114
133
|
// get usage summaries for this billing cycle
|
|
115
134
|
expandedItems = await Promise.all(
|
|
116
135
|
expandedItems.filter(filter).map(async (x: any) => {
|
|
@@ -119,8 +138,8 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
119
138
|
if (x.price.recurring?.usage_type === 'metered') {
|
|
120
139
|
const rawQuantity = await UsageRecord.getSummary({
|
|
121
140
|
id: x.id,
|
|
122
|
-
start:
|
|
123
|
-
end:
|
|
141
|
+
start: usageReportStart,
|
|
142
|
+
end: usageReportEnd,
|
|
124
143
|
method: x.price.recurring?.aggregate_usage,
|
|
125
144
|
dryRun: false,
|
|
126
145
|
});
|
|
@@ -177,6 +196,8 @@ const doHandleSubscriptionInvoice = async ({
|
|
|
177
196
|
} as Invoice,
|
|
178
197
|
});
|
|
179
198
|
|
|
199
|
+
logger.info('Invoice created for subscription', { invoice: invoice.id, subscription: subscription.id });
|
|
200
|
+
|
|
180
201
|
return invoice;
|
|
181
202
|
};
|
|
182
203
|
|
|
@@ -401,6 +422,7 @@ const handleStakeSlashAfterCancel = async (subscription: Subscription) => {
|
|
|
401
422
|
amount: invoice.amount_remaining,
|
|
402
423
|
address,
|
|
403
424
|
txHash,
|
|
425
|
+
invoice: invoice.id,
|
|
404
426
|
});
|
|
405
427
|
|
|
406
428
|
await paymentIntent.update({
|
|
@@ -645,7 +667,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
645
667
|
previousStatus,
|
|
646
668
|
});
|
|
647
669
|
|
|
648
|
-
if (previousStatus === 'past_due') {
|
|
670
|
+
if (previousStatus === 'past_due' && job.action === 'cancel') {
|
|
649
671
|
await handleStakeSlashAfterCancel(subscription);
|
|
650
672
|
}
|
|
651
673
|
return;
|
|
@@ -689,21 +711,48 @@ export const subscriptionQueue = createQueue<SubscriptionJob>({
|
|
|
689
711
|
});
|
|
690
712
|
|
|
691
713
|
export const startSubscriptionQueue = async () => {
|
|
692
|
-
const
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
714
|
+
const lock = getLock('startSubscriptionQueue');
|
|
715
|
+
if (lock.locked) {
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
logger.info('startSubscriptionQueue');
|
|
719
|
+
try {
|
|
720
|
+
await lock.acquire();
|
|
721
|
+
const subscriptions = await Subscription.findAll({
|
|
722
|
+
where: {
|
|
723
|
+
status: EXPECTED_SUBSCRIPTION_STATUS,
|
|
724
|
+
},
|
|
725
|
+
});
|
|
697
726
|
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
727
|
+
const results = await Promise.allSettled(
|
|
728
|
+
subscriptions.map(async (x) => {
|
|
729
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
730
|
+
if (supportAutoCharge === false) {
|
|
731
|
+
return;
|
|
732
|
+
}
|
|
733
|
+
if (['past_due', 'paused'].includes(x.status)) {
|
|
734
|
+
logger.info(`skip add cycle subscription job because status is ${x.status}`, {
|
|
735
|
+
subscription: x.id,
|
|
736
|
+
action: 'cycle',
|
|
737
|
+
});
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
logger.info('add subscription job', { subscription: x.id, action: 'cycle' });
|
|
741
|
+
await addSubscriptionJob(x, 'cycle', process.env.PAYMENT_RELOAD_SUBSCRIPTION_JOBS === '1');
|
|
742
|
+
})
|
|
743
|
+
);
|
|
744
|
+
|
|
745
|
+
const failed = results.filter((r) => r.status === 'rejected').length;
|
|
746
|
+
if (failed > 0) {
|
|
747
|
+
logger.warn(`Failed to process ${failed} subscriptions in startSubscriptionQueue`);
|
|
702
748
|
}
|
|
703
|
-
await addSubscriptionJob(x, 'cycle', process.env.PAYMENT_RELOAD_SUBSCRIPTION_JOBS === '1');
|
|
704
|
-
});
|
|
705
749
|
|
|
706
|
-
|
|
750
|
+
await batchHandleStripeSubscriptions();
|
|
751
|
+
} catch (error) {
|
|
752
|
+
logger.error('Error in startSubscriptionQueue:', error);
|
|
753
|
+
} finally {
|
|
754
|
+
lock.release();
|
|
755
|
+
}
|
|
707
756
|
};
|
|
708
757
|
|
|
709
758
|
export const slashStakeQueue = createQueue({
|
|
@@ -136,6 +136,23 @@ export async function validateInventory(line_items: LineItem[], includePendingQu
|
|
|
136
136
|
await Promise.all(checks);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
const SubscriptionDataSchema = Joi.object({
|
|
140
|
+
service_actions: Joi.array()
|
|
141
|
+
.items(
|
|
142
|
+
Joi.object({
|
|
143
|
+
name: Joi.string().optional(),
|
|
144
|
+
color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').optional(),
|
|
145
|
+
variant: Joi.string().allow('text', 'contained', 'outlined').optional(),
|
|
146
|
+
text: Joi.object().required(),
|
|
147
|
+
link: Joi.string().uri().required(),
|
|
148
|
+
type: Joi.string().allow('notification', 'custom').optional(),
|
|
149
|
+
triggerEvents: Joi.array().items(Joi.string()).optional(),
|
|
150
|
+
})
|
|
151
|
+
)
|
|
152
|
+
.min(0)
|
|
153
|
+
.optional(),
|
|
154
|
+
}).unknown(true);
|
|
155
|
+
|
|
139
156
|
export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = true) => {
|
|
140
157
|
const raw: Partial<CheckoutSession> = Object.assign(
|
|
141
158
|
{
|
|
@@ -161,6 +178,7 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
161
178
|
billing_threshold_amount: 0,
|
|
162
179
|
min_stake_amount: 0,
|
|
163
180
|
trial_end: 0,
|
|
181
|
+
service_actions: [],
|
|
164
182
|
},
|
|
165
183
|
payment_intent_data: {},
|
|
166
184
|
submit_type: 'pay',
|
|
@@ -193,6 +211,13 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
193
211
|
raw.subscription_data.trial_period_days = Number(raw.subscription_data.trial_period_days);
|
|
194
212
|
}
|
|
195
213
|
|
|
214
|
+
if (raw.subscription_data?.service_actions) {
|
|
215
|
+
const { error } = SubscriptionDataSchema.validate(raw.subscription_data);
|
|
216
|
+
if (error) {
|
|
217
|
+
throw new Error('Invalid service actions for checkout session');
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
196
221
|
if (!raw.expires_at) {
|
|
197
222
|
raw.expires_at = dayjs().unix() + CHECKOUT_SESSION_TTL;
|
|
198
223
|
}
|
|
@@ -869,6 +894,7 @@ router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
|
|
|
869
894
|
checkoutSession.subscription_data?.days_until_cancel ?? checkoutSession.metadata?.days_until_cancel,
|
|
870
895
|
recovered_from: recoveredFrom?.id,
|
|
871
896
|
metadata: omit(checkoutSession.metadata || {}, ['days_until_due', 'days_until_cancel']),
|
|
897
|
+
service_actions: checkoutSession.subscription_data?.service_actions || [],
|
|
872
898
|
});
|
|
873
899
|
|
|
874
900
|
logger.info('subscription created on checkout session submit', {
|
|
@@ -20,6 +20,7 @@ import { PaymentMethod } from '../store/models/payment-method';
|
|
|
20
20
|
import { Price } from '../store/models/price';
|
|
21
21
|
import { Product } from '../store/models/product';
|
|
22
22
|
import { Subscription } from '../store/models/subscription';
|
|
23
|
+
import { getSubscriptionStakeAmountSetup } from '../libs/subscription';
|
|
23
24
|
|
|
24
25
|
const router = Router();
|
|
25
26
|
const authAdmin = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
@@ -131,44 +132,23 @@ router.get('/', authMine, async (req, res) => {
|
|
|
131
132
|
if (subscription?.payment_details?.arcblock?.staking?.tx_hash) {
|
|
132
133
|
const method = await PaymentMethod.findOne({ where: { type: 'arcblock', livemode: subscription.livemode } });
|
|
133
134
|
if (method) {
|
|
134
|
-
const client = method.getOcapClient();
|
|
135
135
|
const { address } = subscription.payment_details.arcblock.staking;
|
|
136
|
-
const { state } = await client.getStakeState({ address });
|
|
137
136
|
const firstInvoice = await Invoice.findOne({
|
|
138
137
|
where: { subscription_id: subscription.id },
|
|
139
138
|
order: [['created_at', 'ASC']],
|
|
139
|
+
include: [{ model: PaymentCurrency, as: 'paymentCurrency' }],
|
|
140
140
|
});
|
|
141
141
|
const last = query.o === 'asc' ? list?.[list.length - 1] : list?.[0];
|
|
142
|
-
if (
|
|
143
|
-
const data = JSON.parse(state.data?.value || '{}');
|
|
142
|
+
if (subscription.payment_details.arcblock.staking.tx_hash && firstInvoice) {
|
|
144
143
|
const customer = await Customer.findByPk(firstInvoice.customer_id);
|
|
145
|
-
const currency =
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (!Number(stakeAmount)) {
|
|
154
|
-
if (subscription.cancelation_details?.return_stake) {
|
|
155
|
-
const refund = await Refund.findOne({
|
|
156
|
-
where: { subscription_id: subscription.id, status: 'succeeded', type: 'stake_return' },
|
|
157
|
-
});
|
|
158
|
-
if (refund) {
|
|
159
|
-
stakeAmount = refund.amount;
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (subscription.cancelation_details?.slash_stake) {
|
|
163
|
-
const invoice = await Invoice.findOne({
|
|
164
|
-
where: { subscription_id: subscription.id, status: 'paid', billing_reason: 'slash_stake' },
|
|
165
|
-
});
|
|
166
|
-
if (invoice) {
|
|
167
|
-
stakeAmount = invoice.total;
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
}
|
|
144
|
+
const currency =
|
|
145
|
+
// @ts-ignore
|
|
146
|
+
firstInvoice?.paymentCurrency ||
|
|
147
|
+
(await PaymentCurrency.findOne({
|
|
148
|
+
where: { payment_method_id: method.id, is_base_currency: true },
|
|
149
|
+
}));
|
|
150
|
+
const stakeAmountResult = await getSubscriptionStakeAmountSetup(subscription, method);
|
|
151
|
+
const stakeAmount = stakeAmountResult?.[currency?.contract] || '0';
|
|
172
152
|
|
|
173
153
|
list.push({
|
|
174
154
|
id: address as string,
|
|
@@ -7,6 +7,7 @@ import pick from 'lodash/pick';
|
|
|
7
7
|
import uniq from 'lodash/uniq';
|
|
8
8
|
|
|
9
9
|
import { literal, OrderItem } from 'sequelize';
|
|
10
|
+
import { createEvent } from '../libs/audit';
|
|
10
11
|
import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../integrations/stripe/resource';
|
|
11
12
|
import { createListParamSchema, getWhereFromKvQuery, getWhereFromQuery, MetadataSchema } from '../libs/api';
|
|
12
13
|
import dayjs from '../libs/dayjs';
|
|
@@ -692,11 +693,13 @@ const updateSchema = Joi.object<{
|
|
|
692
693
|
service_actions: Joi.array()
|
|
693
694
|
.items(
|
|
694
695
|
Joi.object({
|
|
695
|
-
name: Joi.string().
|
|
696
|
-
color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').
|
|
697
|
-
variant: Joi.string().allow('text', 'contained', 'outlined').
|
|
696
|
+
name: Joi.string().optional(),
|
|
697
|
+
color: Joi.string().allow('primary', 'secondary', 'success', 'error', 'warning').optional(),
|
|
698
|
+
variant: Joi.string().allow('text', 'contained', 'outlined').optional(),
|
|
698
699
|
text: Joi.object().required(),
|
|
699
700
|
link: Joi.string().uri().required(),
|
|
701
|
+
type: Joi.string().allow('notification', 'custom').optional(),
|
|
702
|
+
triggerEvents: Joi.array().items(Joi.string()).optional(),
|
|
700
703
|
})
|
|
701
704
|
)
|
|
702
705
|
.optional(),
|
|
@@ -851,10 +854,15 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
851
854
|
// update subscription period settings
|
|
852
855
|
// HINT: if we are adding new items, we need to reset the anchor to now
|
|
853
856
|
const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0);
|
|
857
|
+
// Check if the subscription is currently in trial
|
|
858
|
+
const isInTrial =
|
|
859
|
+
subscription.status === 'trialing' && subscription.trial_end && subscription.trial_end > dayjs().unix();
|
|
854
860
|
if (newItems.some((x) => x.price.type === 'recurring' && addedItems.find((y) => y.price_id === x.price_id))) {
|
|
855
861
|
updates.pending_invoice_item_interval = setup.recurring;
|
|
856
|
-
|
|
857
|
-
|
|
862
|
+
if (!isInTrial) {
|
|
863
|
+
updates.current_period_start = setup.period.start;
|
|
864
|
+
updates.current_period_end = setup.period.end;
|
|
865
|
+
}
|
|
858
866
|
updates.billing_cycle_anchor = setup.cycle.anchor;
|
|
859
867
|
logger.info('subscription updates on reset anchor', { subscription: req.params.id, updates });
|
|
860
868
|
}
|
|
@@ -869,12 +877,30 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
869
877
|
}
|
|
870
878
|
|
|
871
879
|
// 1. create proration
|
|
872
|
-
const { lastInvoice, due, newCredit, appliedCredit, prorations } = await createProration(
|
|
880
|
+
const { lastInvoice, due, newCredit, appliedCredit, prorations, total } = await createProration(
|
|
873
881
|
subscription,
|
|
874
882
|
setup,
|
|
875
883
|
dayjs().unix()
|
|
876
884
|
);
|
|
877
885
|
|
|
886
|
+
if ((total === '0' && isInTrial) || newCredit !== '0') {
|
|
887
|
+
// 0 amount or new credit means no need to create invoice
|
|
888
|
+
await subscription.update(updates);
|
|
889
|
+
await finalizeSubscriptionUpdate({
|
|
890
|
+
subscription,
|
|
891
|
+
customer,
|
|
892
|
+
invoice: null,
|
|
893
|
+
paymentCurrency,
|
|
894
|
+
appliedCredit,
|
|
895
|
+
newCredit,
|
|
896
|
+
addedItems,
|
|
897
|
+
deletedItems,
|
|
898
|
+
updatedItems,
|
|
899
|
+
updates,
|
|
900
|
+
});
|
|
901
|
+
await createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(console.error);
|
|
902
|
+
return res.json({ ...subscription.toJSON(), connectAction });
|
|
903
|
+
}
|
|
878
904
|
// 2. create new invoice: amount according to new subscription items
|
|
879
905
|
// 3. create new invoice items: amount according to new subscription items
|
|
880
906
|
const result = await ensureInvoiceAndItems({
|
|
@@ -894,7 +920,7 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
894
920
|
period_end: setup.period.end,
|
|
895
921
|
auto_advance: true,
|
|
896
922
|
billing_reason: 'subscription_update',
|
|
897
|
-
total
|
|
923
|
+
total,
|
|
898
924
|
currency_id: paymentCurrency.id,
|
|
899
925
|
default_payment_method_id: subscription.default_payment_method_id,
|
|
900
926
|
custom_fields: lastInvoice.custom_fields || [],
|
|
@@ -977,6 +1003,16 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
977
1003
|
invoice: invoice.id,
|
|
978
1004
|
});
|
|
979
1005
|
} else {
|
|
1006
|
+
await subscription.update({
|
|
1007
|
+
pending_update: {
|
|
1008
|
+
updates,
|
|
1009
|
+
appliedCredit,
|
|
1010
|
+
newCredit,
|
|
1011
|
+
addedItems,
|
|
1012
|
+
deletedItems,
|
|
1013
|
+
updatedItems,
|
|
1014
|
+
},
|
|
1015
|
+
});
|
|
980
1016
|
await invoiceQueue.pushAndWait({
|
|
981
1017
|
id: invoice.id,
|
|
982
1018
|
job: { invoiceId: invoice.id, retryOnError: false, waitForPayment: true },
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
NftMintSettings,
|
|
25
25
|
PaymentDetails,
|
|
26
26
|
PaymentIntentData,
|
|
27
|
+
ServiceAction,
|
|
27
28
|
SubscriptionData,
|
|
28
29
|
} from './types';
|
|
29
30
|
|
|
@@ -158,6 +159,7 @@ export class CheckoutSession extends Model<InferAttributes<CheckoutSession>, Inf
|
|
|
158
159
|
|
|
159
160
|
// When creating a subscription, the specified configuration data will be used.
|
|
160
161
|
declare subscription_data?: SubscriptionData & {
|
|
162
|
+
service_actions?: ServiceAction[];
|
|
161
163
|
billing_cycle_anchor?: number;
|
|
162
164
|
metadata?: Record<string, any>;
|
|
163
165
|
proration_behavior?: LiteralUnion<'create_prorations' | 'none', string>;
|
|
@@ -70,6 +70,10 @@ export class Job extends Model<InferAttributes<Job>, InferCreationAttributes<Job
|
|
|
70
70
|
public static associate() {
|
|
71
71
|
// Do nothing
|
|
72
72
|
}
|
|
73
|
+
|
|
74
|
+
public static isInitialized(): boolean {
|
|
75
|
+
return this.sequelize !== undefined;
|
|
76
|
+
}
|
|
73
77
|
}
|
|
74
78
|
|
|
75
79
|
export type TJob = InferAttributes<Job>;
|
|
@@ -44,10 +44,26 @@ export type PriceCurrency = {
|
|
|
44
44
|
custom_unit_amount: CustomUnitAmount | null;
|
|
45
45
|
};
|
|
46
46
|
|
|
47
|
+
// 这里为triggerEvents的事件类型
|
|
48
|
+
type NotificationActionEvents =
|
|
49
|
+
| 'customer.subscription.started'
|
|
50
|
+
| 'customer.subscription.renewed'
|
|
51
|
+
| 'customer.subscription.renew_failed'
|
|
52
|
+
| 'refund.succeeded'
|
|
53
|
+
| 'subscription.stake.slash.succeeded'
|
|
54
|
+
| 'customer.subscription.trial_will_end'
|
|
55
|
+
| 'customer.subscription.trial_start'
|
|
56
|
+
| 'customer.subscription.upgraded'
|
|
57
|
+
| 'customer.subscription.will_renew'
|
|
58
|
+
| 'customer.subscription.will_canceled'
|
|
59
|
+
| 'customer.subscription.deleted';
|
|
60
|
+
|
|
47
61
|
export type ServiceAction = {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
62
|
+
type?: LiteralUnion<'notification' | 'custom', string>;
|
|
63
|
+
triggerEvents?: NotificationActionEvents[];
|
|
64
|
+
name?: string;
|
|
65
|
+
color?: LiteralUnion<'primary' | 'secondary' | 'success' | 'error' | 'warning', string>;
|
|
66
|
+
variant?: LiteralUnion<'text' | 'contained' | 'outlined', string>;
|
|
51
67
|
text: { [key: string]: string };
|
|
52
68
|
link: string;
|
|
53
69
|
};
|
|
@@ -640,7 +656,9 @@ export type EventType = LiteralUnion<
|
|
|
640
656
|
| 'topup.succeeded'
|
|
641
657
|
| 'transfer.created'
|
|
642
658
|
| 'transfer.reversed'
|
|
643
|
-
| 'transfer.updated'
|
|
659
|
+
| 'transfer.updated'
|
|
660
|
+
| 'billing.discrepancy'
|
|
661
|
+
| 'usage.report.empty',
|
|
644
662
|
string
|
|
645
663
|
>;
|
|
646
664
|
|
|
@@ -100,17 +100,21 @@ export class UsageRecord extends Model<InferAttributes<UsageRecord>, InferCreati
|
|
|
100
100
|
end,
|
|
101
101
|
method,
|
|
102
102
|
dryRun,
|
|
103
|
+
billed = false,
|
|
104
|
+
searchBilled = true,
|
|
103
105
|
}: {
|
|
104
106
|
id: string;
|
|
105
107
|
start: number;
|
|
106
108
|
end: number;
|
|
107
109
|
method: LiteralUnion<'sum' | 'last_during_period' | 'max' | 'last_ever', string>;
|
|
108
110
|
dryRun: boolean;
|
|
111
|
+
billed?: boolean;
|
|
112
|
+
searchBilled?: boolean;
|
|
109
113
|
}): Promise<number> {
|
|
110
114
|
const query = {
|
|
111
115
|
where: {
|
|
112
116
|
subscription_item_id: id,
|
|
113
|
-
billed:
|
|
117
|
+
...(searchBilled ? { billed } : {}),
|
|
114
118
|
timestamp: {
|
|
115
119
|
[Op.gt]: start,
|
|
116
120
|
[Op.lte]: end,
|
|
@@ -9,7 +9,10 @@ import {
|
|
|
9
9
|
getSubscriptionStakeSetup,
|
|
10
10
|
getSubscriptionTrialSetup,
|
|
11
11
|
shouldCancelSubscription,
|
|
12
|
+
getSubscriptionStakeAmountSetup,
|
|
13
|
+
checkUsageReportEmpty,
|
|
12
14
|
} from '../../src/libs/subscription';
|
|
15
|
+
import { PaymentMethod, Subscription, SubscriptionItem, UsageRecord, Price } from '../../src/store/models';
|
|
13
16
|
|
|
14
17
|
describe('getDueUnit', () => {
|
|
15
18
|
it('should return 60 for recurring interval of "hour"', () => {
|
|
@@ -411,3 +414,154 @@ describe('getSubscriptionTrialSetup', () => {
|
|
|
411
414
|
expect(result).toEqual({ trialInDays: 0, trialEnd: 0 });
|
|
412
415
|
});
|
|
413
416
|
});
|
|
417
|
+
|
|
418
|
+
describe('getSubscriptionStakeAmountSetup', () => {
|
|
419
|
+
let mockSubscription: Subscription;
|
|
420
|
+
let mockPaymentMethod: PaymentMethod;
|
|
421
|
+
let mockGetOcapClient: jest.Mock;
|
|
422
|
+
let mockGetTx: jest.Mock;
|
|
423
|
+
|
|
424
|
+
beforeEach(() => {
|
|
425
|
+
mockSubscription = {
|
|
426
|
+
payment_details: {
|
|
427
|
+
arcblock: {
|
|
428
|
+
staking: {
|
|
429
|
+
tx_hash: 'mock_tx_hash',
|
|
430
|
+
},
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
} as Subscription;
|
|
434
|
+
|
|
435
|
+
mockGetTx = jest.fn();
|
|
436
|
+
mockGetOcapClient = jest.fn().mockReturnValue({
|
|
437
|
+
getTx: mockGetTx,
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
// @ts-ignore
|
|
441
|
+
mockPaymentMethod = {
|
|
442
|
+
type: 'arcblock',
|
|
443
|
+
getOcapClient: mockGetOcapClient,
|
|
444
|
+
};
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
it('should return null if payment method is not arcblock', async () => {
|
|
448
|
+
mockPaymentMethod.type = 'other';
|
|
449
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
450
|
+
expect(result).toBeNull();
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
it('should return null if tx_hash is missing', async () => {
|
|
454
|
+
// @ts-ignore
|
|
455
|
+
mockSubscription.payment_details.arcblock.staking.tx_hash = undefined;
|
|
456
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
457
|
+
expect(result).toBeNull();
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('should return null if getTx info is null', async () => {
|
|
461
|
+
mockGetTx.mockResolvedValue({ info: null });
|
|
462
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
463
|
+
expect(result).toBeNull();
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('should return null if inputs are empty', async () => {
|
|
467
|
+
mockGetTx.mockResolvedValue({
|
|
468
|
+
info: {
|
|
469
|
+
tx: {
|
|
470
|
+
itxJson: {
|
|
471
|
+
inputs: [],
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
},
|
|
475
|
+
});
|
|
476
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
477
|
+
expect(result).toBeNull();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
it('should calculate stake amount correctly', async () => {
|
|
481
|
+
mockGetTx.mockResolvedValue({
|
|
482
|
+
info: {
|
|
483
|
+
tx: {
|
|
484
|
+
itxJson: {
|
|
485
|
+
inputs: [
|
|
486
|
+
{
|
|
487
|
+
tokens: [
|
|
488
|
+
{ address: 'addr1', value: '100' },
|
|
489
|
+
{ address: 'addr2', value: '200' },
|
|
490
|
+
],
|
|
491
|
+
},
|
|
492
|
+
{
|
|
493
|
+
tokens: [
|
|
494
|
+
{ address: 'addr1', value: '300' },
|
|
495
|
+
{ address: 'addr3', value: '400' },
|
|
496
|
+
],
|
|
497
|
+
},
|
|
498
|
+
],
|
|
499
|
+
},
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
const result = await getSubscriptionStakeAmountSetup(mockSubscription, mockPaymentMethod);
|
|
505
|
+
expect(result).toEqual({
|
|
506
|
+
addr1: '400',
|
|
507
|
+
addr2: '200',
|
|
508
|
+
addr3: '400',
|
|
509
|
+
});
|
|
510
|
+
});
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
// 模拟的依赖项
|
|
514
|
+
const mockSubscriptionItems = [
|
|
515
|
+
{ id: 'item_1', price_id: 'price_1', quantity: 1 },
|
|
516
|
+
{ id: 'item_2', price_id: 'price_2', quantity: 1 },
|
|
517
|
+
];
|
|
518
|
+
|
|
519
|
+
const mockExpandedItems = [
|
|
520
|
+
{ id: 'item_1', price: { recurring: { usage_type: 'metered' } } },
|
|
521
|
+
{ id: 'item_2', price: { recurring: { usage_type: 'metered' } } },
|
|
522
|
+
];
|
|
523
|
+
|
|
524
|
+
const mockUsageRecordsEmpty: any[] = [];
|
|
525
|
+
const mockUsageRecordsWithData = [{ id: 'usage_1' }];
|
|
526
|
+
|
|
527
|
+
describe('checkUsageReportEmpty', () => {
|
|
528
|
+
const subscription = { id: 'sub_123' };
|
|
529
|
+
const usageReportStart = 1622505600;
|
|
530
|
+
const usageReportEnd = 1622592000;
|
|
531
|
+
|
|
532
|
+
beforeEach(() => {
|
|
533
|
+
// Reset any state if necessary
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
it('should return true if there are no usage records', async () => {
|
|
537
|
+
// Mock the behavior of the functions directly
|
|
538
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
|
|
539
|
+
jest.spyOn(Price, 'expand').mockResolvedValue(mockExpandedItems as any);
|
|
540
|
+
jest.spyOn(UsageRecord, 'findAll').mockResolvedValue(mockUsageRecordsEmpty);
|
|
541
|
+
|
|
542
|
+
const result = await checkUsageReportEmpty(subscription as any, usageReportStart, usageReportEnd);
|
|
543
|
+
expect(result).toBe(true);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
it('should return false if there are usage records', async () => {
|
|
547
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
|
|
548
|
+
jest.spyOn(Price, 'expand').mockResolvedValue(mockExpandedItems as any);
|
|
549
|
+
jest.spyOn(UsageRecord, 'findAll').mockResolvedValue(mockUsageRecordsWithData as any);
|
|
550
|
+
|
|
551
|
+
const result = await checkUsageReportEmpty(subscription as any, usageReportStart, usageReportEnd);
|
|
552
|
+
expect(result).toBe(false);
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
it('should handle multiple metered items', async () => {
|
|
556
|
+
jest.spyOn(SubscriptionItem, 'findAll').mockResolvedValue(mockSubscriptionItems as any);
|
|
557
|
+
jest.spyOn(Price, 'expand').mockResolvedValue(mockExpandedItems as any);
|
|
558
|
+
|
|
559
|
+
jest
|
|
560
|
+
.spyOn(UsageRecord, 'findAll')
|
|
561
|
+
.mockResolvedValueOnce(mockUsageRecordsEmpty)
|
|
562
|
+
.mockResolvedValueOnce(mockUsageRecordsWithData as any);
|
|
563
|
+
|
|
564
|
+
const result = await checkUsageReportEmpty(subscription as any, usageReportStart, usageReportEnd);
|
|
565
|
+
expect(result).toBe(false);
|
|
566
|
+
});
|
|
567
|
+
});
|