payment-kit 1.13.29 → 1.13.31
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/blockchain/nft.ts +0 -1
- package/api/src/integrations/blocklet/passport.ts +1 -1
- package/api/src/integrations/stripe/handlers/invoice.ts +44 -2
- package/api/src/integrations/stripe/handlers/payment-intent.ts +32 -8
- package/api/src/integrations/stripe/resource.ts +7 -4
- package/api/src/jobs/subscription.ts +1 -1
- package/api/src/libs/payment.ts +6 -1
- package/api/src/libs/session.ts +78 -27
- package/api/src/libs/util.ts +15 -0
- package/api/src/routes/checkout-sessions.ts +161 -20
- package/api/src/routes/connect/collect.ts +5 -9
- package/api/src/routes/connect/pay.ts +5 -9
- package/api/src/routes/connect/setup.ts +22 -10
- package/api/src/routes/connect/shared.ts +13 -10
- package/api/src/routes/connect/subscribe.ts +29 -20
- package/api/src/routes/invoices.ts +5 -1
- package/api/src/routes/payment-intents.ts +5 -1
- package/api/src/routes/payment-links.ts +3 -2
- package/api/src/routes/prices.ts +32 -21
- package/api/src/routes/products.ts +1 -9
- package/api/src/store/migrations/20231023-upsell.ts +11 -0
- package/api/src/store/models/index.ts +10 -2
- package/api/src/store/models/price.ts +89 -23
- package/api/src/store/models/types.ts +1 -0
- package/blocklet.yml +1 -1
- package/package.json +17 -17
- package/src/components/blockchain/tx.tsx +3 -1
- package/src/components/checkout/pay.tsx +39 -19
- package/src/components/checkout/product-card.tsx +2 -6
- package/src/components/checkout/product-item.tsx +84 -21
- package/src/components/checkout/summary.tsx +11 -2
- package/src/components/info-row.tsx +3 -1
- package/src/components/invoice/table.tsx +1 -1
- package/src/components/price/upsell-select.tsx +83 -0
- package/src/components/price/upsell.tsx +74 -0
- package/src/components/status.tsx +1 -1
- package/src/components/subscription/actions/cancel.tsx +25 -27
- package/src/components/subscription/items/index.tsx +1 -1
- package/src/libs/util.ts +51 -31
- package/src/locales/en.tsx +23 -2
- package/src/locales/zh.tsx +52 -40
- package/src/pages/admin/billing/index.tsx +3 -3
- package/src/pages/admin/customers/customers/detail.tsx +1 -0
- package/src/pages/admin/index.tsx +1 -0
- package/src/pages/admin/products/prices/detail.tsx +7 -0
- package/src/pages/customer/invoice.tsx +7 -6
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { getUrl } from '@blocklet/sdk/lib/component';
|
|
3
3
|
import userMiddleware from '@blocklet/sdk/lib/middlewares/user';
|
|
4
4
|
import { Request, Response, Router } from 'express';
|
|
5
|
+
import cloneDeep from 'lodash/cloneDeep';
|
|
6
|
+
import merge from 'lodash/merge';
|
|
5
7
|
import omit from 'lodash/omit';
|
|
6
8
|
import pick from 'lodash/pick';
|
|
7
9
|
import sortBy from 'lodash/sortBy';
|
|
@@ -19,8 +21,10 @@ import logger from '../libs/logger';
|
|
|
19
21
|
import { isDelegationSufficientForPayment } from '../libs/payment';
|
|
20
22
|
import { authenticate } from '../libs/security';
|
|
21
23
|
import {
|
|
24
|
+
canUpsell,
|
|
22
25
|
getCheckoutAmount,
|
|
23
26
|
getCheckoutMode,
|
|
27
|
+
getFastCheckoutAmount,
|
|
24
28
|
getStatementDescriptor,
|
|
25
29
|
getSubscriptionCreateSetup,
|
|
26
30
|
getSupportedPaymentCurrencies,
|
|
@@ -28,7 +32,7 @@ import {
|
|
|
28
32
|
isLineItemAligned,
|
|
29
33
|
} from '../libs/session';
|
|
30
34
|
import { createCodeGenerator, formatMetadata } from '../libs/util';
|
|
31
|
-
import type {
|
|
35
|
+
import type { TPaymentCurrency } from '../store/models';
|
|
32
36
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
33
37
|
import { Customer } from '../store/models/customer';
|
|
34
38
|
import { PaymentCurrency } from '../store/models/payment-currency';
|
|
@@ -137,7 +141,7 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
137
141
|
throw new Error('line items should not be empty for checkout session');
|
|
138
142
|
}
|
|
139
143
|
|
|
140
|
-
const items = await Price.expand(raw.line_items as
|
|
144
|
+
const items = await Price.expand(raw.line_items as any[]);
|
|
141
145
|
if (items.some((x) => !x.price)) {
|
|
142
146
|
throw new Error('Invalid line items for checkout session, some price may have been deleted');
|
|
143
147
|
}
|
|
@@ -159,8 +163,6 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
159
163
|
raw.currency_id = items[0]?.price.currency_id;
|
|
160
164
|
}
|
|
161
165
|
|
|
162
|
-
const currency = await PaymentCurrency.findByPk(raw.currency_id);
|
|
163
|
-
const amount = getCheckoutAmount(items, currency as TPaymentCurrency, !!raw.subscription_data?.trial_period_days);
|
|
164
166
|
const mode = getCheckoutMode(items);
|
|
165
167
|
|
|
166
168
|
return Object.assign(raw, {
|
|
@@ -168,7 +170,19 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
168
170
|
status: 'open',
|
|
169
171
|
payment_status: 'unpaid',
|
|
170
172
|
nft_mint_status: raw.nft_mint_settings?.enabled ? 'pending' : 'disabled',
|
|
173
|
+
payment_method_types: await getPaymentTypes(items),
|
|
174
|
+
// always create invoice for subscriptions
|
|
175
|
+
invoice_creation: mode === 'subscription' ? { enabled: true } : raw.invoice_creation,
|
|
176
|
+
...(await getCheckoutSessionAmounts(raw as CheckoutSession)),
|
|
177
|
+
});
|
|
178
|
+
};
|
|
171
179
|
|
|
180
|
+
export async function getCheckoutSessionAmounts(checkoutSession: CheckoutSession) {
|
|
181
|
+
const items = await Price.expand(checkoutSession.line_items);
|
|
182
|
+
const currency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
|
|
183
|
+
const includeTrial = !!checkoutSession.subscription_data?.trial_period_days;
|
|
184
|
+
const amount = getCheckoutAmount(items, currency as TPaymentCurrency, includeTrial);
|
|
185
|
+
return {
|
|
172
186
|
amount_subtotal: amount.subtotal,
|
|
173
187
|
amount_total: amount.total,
|
|
174
188
|
total_details: {
|
|
@@ -176,13 +190,8 @@ export const formatCheckoutSession = async (payload: any, throwOnEmptyItems = tr
|
|
|
176
190
|
amount_shipping: amount.shipping,
|
|
177
191
|
amount_tax: amount.tax,
|
|
178
192
|
},
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// always create invoice for subscriptions
|
|
183
|
-
invoice_creation: mode === 'subscription' ? { enabled: true } : raw.invoice_creation,
|
|
184
|
-
});
|
|
185
|
-
};
|
|
193
|
+
};
|
|
194
|
+
}
|
|
186
195
|
|
|
187
196
|
// create checkout session
|
|
188
197
|
router.post('/', auth, async (req, res) => {
|
|
@@ -206,7 +215,7 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
|
|
|
206
215
|
return;
|
|
207
216
|
}
|
|
208
217
|
|
|
209
|
-
const items = await Price.expand(link.line_items);
|
|
218
|
+
const items = await Price.expand(link.line_items, { upsell: true });
|
|
210
219
|
|
|
211
220
|
const raw: Partial<CheckoutSession> = await formatCheckoutSession(link, false);
|
|
212
221
|
raw.livemode = link.livemode;
|
|
@@ -214,6 +223,20 @@ export async function startCheckoutSessionFromPaymentLink(id: string, req: Reque
|
|
|
214
223
|
raw.currency_id = link.currency_id || req.currency.id;
|
|
215
224
|
raw.payment_link_id = link.id;
|
|
216
225
|
|
|
226
|
+
if (link.after_completion?.hosted_confirmation?.custom_message) {
|
|
227
|
+
raw.payment_intent_data = {
|
|
228
|
+
description: link.after_completion?.hosted_confirmation?.custom_message,
|
|
229
|
+
};
|
|
230
|
+
} else {
|
|
231
|
+
raw.payment_intent_data = {
|
|
232
|
+
// TODO: bake default into this
|
|
233
|
+
description: 'Thanks for your purchase',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
if (link.after_completion?.redirect?.url) {
|
|
237
|
+
raw.success_url = link.after_completion?.redirect?.url;
|
|
238
|
+
}
|
|
239
|
+
|
|
217
240
|
if (req.query.redirect) {
|
|
218
241
|
raw.success_url = req.query.redirect as string;
|
|
219
242
|
raw.cancel_url = req.query.redirect as string;
|
|
@@ -268,7 +291,7 @@ router.get('/:id', auth, async (req, res) => {
|
|
|
268
291
|
|
|
269
292
|
if (doc) {
|
|
270
293
|
// @ts-ignore
|
|
271
|
-
doc.line_items = await Price.expand(doc.line_items);
|
|
294
|
+
doc.line_items = await Price.expand(doc.line_items, { upsell: true });
|
|
272
295
|
doc.url = getUrl(`/checkout/${doc.submit_type}/${doc.id}`);
|
|
273
296
|
}
|
|
274
297
|
|
|
@@ -285,7 +308,7 @@ router.get('/retrieve/:id', user, async (req, res) => {
|
|
|
285
308
|
}
|
|
286
309
|
|
|
287
310
|
// @ts-ignore
|
|
288
|
-
doc.line_items = await Price.expand(doc.line_items);
|
|
311
|
+
doc.line_items = await Price.expand(doc.line_items, { upsell: true });
|
|
289
312
|
|
|
290
313
|
// check payment intent
|
|
291
314
|
const paymentIntent = doc.payment_intent_id ? await PaymentIntent.findByPk(doc.payment_intent_id) : null;
|
|
@@ -334,7 +357,7 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
334
357
|
await checkoutSession.update({ currency_id: paymentCurrency.id });
|
|
335
358
|
|
|
336
359
|
// always update payment amount in case currency has changed
|
|
337
|
-
const lineItems = await Price.expand(checkoutSession.line_items, true);
|
|
360
|
+
const lineItems = await Price.expand(checkoutSession.line_items, { product: true, upsell: true });
|
|
338
361
|
const trialInDays = checkoutSession.subscription_data?.trial_period_days || 0;
|
|
339
362
|
const amount = getCheckoutAmount(lineItems, paymentCurrency, !!trialInDays);
|
|
340
363
|
await checkoutSession.update({
|
|
@@ -364,6 +387,7 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
364
387
|
delinquent: false,
|
|
365
388
|
invoice_prefix: getInvoicePrefix(),
|
|
366
389
|
});
|
|
390
|
+
logger.info('customer created on checkout session submit', { did: req.user.did, id: customer.id });
|
|
367
391
|
} else {
|
|
368
392
|
const updates: Record<string, string> = {};
|
|
369
393
|
if (checkoutSession.customer_update?.name) {
|
|
@@ -429,6 +453,10 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
429
453
|
setup_future_usage: 'on_session',
|
|
430
454
|
metadata: checkoutSession.payment_intent_data?.metadata || checkoutSession.metadata,
|
|
431
455
|
});
|
|
456
|
+
logger.info('paymentIntent created on checkout session submit', {
|
|
457
|
+
session: checkoutSession.id,
|
|
458
|
+
intent: paymentIntent.id,
|
|
459
|
+
});
|
|
432
460
|
|
|
433
461
|
// lock prices used by this payment
|
|
434
462
|
await Price.update({ locked: true }, { where: { id: lineItems.map((x) => x.price_id) } });
|
|
@@ -473,6 +501,10 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
473
501
|
usage: 'off_session',
|
|
474
502
|
metadata: checkoutSession.metadata,
|
|
475
503
|
});
|
|
504
|
+
logger.info('setupIntent created on checkout session submit', {
|
|
505
|
+
session: checkoutSession.id,
|
|
506
|
+
intent: setupIntent.id,
|
|
507
|
+
});
|
|
476
508
|
|
|
477
509
|
// persist setup intent id
|
|
478
510
|
await checkoutSession.update({ setup_intent_id: setupIntent.id });
|
|
@@ -520,8 +552,13 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
520
552
|
metadata: checkoutSession.metadata as any,
|
|
521
553
|
});
|
|
522
554
|
|
|
555
|
+
logger.info('subscription created on checkout session submit', {
|
|
556
|
+
session: checkoutSession.id,
|
|
557
|
+
subscription: subscription.id,
|
|
558
|
+
});
|
|
559
|
+
|
|
523
560
|
// create subscription items
|
|
524
|
-
await Promise.all(
|
|
561
|
+
const items = await Promise.all(
|
|
525
562
|
lineItems
|
|
526
563
|
.filter((x) => x.price.type === 'recurring')
|
|
527
564
|
.map((x) =>
|
|
@@ -529,15 +566,19 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
529
566
|
livemode: !!checkoutSession.livemode,
|
|
530
567
|
// @ts-ignore
|
|
531
568
|
subscription_id: subscription.id,
|
|
532
|
-
price_id: x.price_id,
|
|
569
|
+
price_id: x.upsell_price_id || x.price_id,
|
|
533
570
|
quantity: x.quantity,
|
|
534
571
|
metadata: checkoutSession.metadata as any,
|
|
535
572
|
})
|
|
536
573
|
)
|
|
537
574
|
);
|
|
575
|
+
logger.info('subscription items created on checkout session submit', {
|
|
576
|
+
session: checkoutSession.id,
|
|
577
|
+
items: items.map((x) => x.id),
|
|
578
|
+
});
|
|
538
579
|
|
|
539
580
|
// lock prices used by this subscription
|
|
540
|
-
await Price.update({ locked: true }, { where: { id: lineItems.map((x) => x.price_id) } });
|
|
581
|
+
await Price.update({ locked: true }, { where: { id: lineItems.map((x) => x.upsell_price_id || x.price_id) } });
|
|
541
582
|
|
|
542
583
|
// persist subscription id
|
|
543
584
|
await checkoutSession.update({ subscription_id: subscription.id });
|
|
@@ -545,11 +586,12 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
545
586
|
}
|
|
546
587
|
|
|
547
588
|
// if we can complete purchase without any wallet interaction
|
|
589
|
+
const fastCheckoutAmount = getFastCheckoutAmount(lineItems, checkoutSession.mode, paymentCurrency, !!trialInDays);
|
|
548
590
|
const delegation = await isDelegationSufficientForPayment({
|
|
549
591
|
paymentMethod,
|
|
550
592
|
paymentCurrency,
|
|
551
593
|
userDid: customer.did,
|
|
552
|
-
amount:
|
|
594
|
+
amount: fastCheckoutAmount,
|
|
553
595
|
});
|
|
554
596
|
if (delegation.sufficient) {
|
|
555
597
|
const paymentSettings = {
|
|
@@ -623,7 +665,7 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
623
665
|
subscription,
|
|
624
666
|
paymentMethod,
|
|
625
667
|
paymentCurrency,
|
|
626
|
-
lineItems
|
|
668
|
+
lineItems,
|
|
627
669
|
trialInDays
|
|
628
670
|
);
|
|
629
671
|
if (stripeSubscription && subscription?.payment_details?.stripe?.subscription_id === stripeSubscription.id) {
|
|
@@ -651,6 +693,105 @@ router.put('/:id/submit', user, async (req, res) => {
|
|
|
651
693
|
}
|
|
652
694
|
});
|
|
653
695
|
|
|
696
|
+
// upsell
|
|
697
|
+
router.put('/:id/upsell', user, async (req, res) => {
|
|
698
|
+
try {
|
|
699
|
+
// validate session
|
|
700
|
+
const checkoutSession = await CheckoutSession.findByPk(req.params.id);
|
|
701
|
+
if (!checkoutSession) {
|
|
702
|
+
return res.status(404).json({ error: 'Checkout session not found' });
|
|
703
|
+
}
|
|
704
|
+
if (checkoutSession.status === 'complete') {
|
|
705
|
+
return res.status(403).json({ error: 'Checkout session completed' });
|
|
706
|
+
}
|
|
707
|
+
if (checkoutSession.status === 'expired') {
|
|
708
|
+
return res.status(403).json({ error: 'Checkout session expired' });
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
if (checkoutSession.line_items) {
|
|
712
|
+
// validate line items
|
|
713
|
+
if (checkoutSession.line_items.length > 1) {
|
|
714
|
+
return res.status(400).json({ error: 'Upsell not supported for checkoutSession with multiple line items' });
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
// validate from and to
|
|
718
|
+
const [from, to] = await Promise.all([Price.findByPk(req.body.from), Price.findByPk(req.body.to)]);
|
|
719
|
+
if (!from) {
|
|
720
|
+
return res.status(400).json({ error: 'Upsell from price not found' });
|
|
721
|
+
}
|
|
722
|
+
if (!to) {
|
|
723
|
+
return res.status(400).json({ error: 'Upsell to price not found' });
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (canUpsell(from, to) === false) {
|
|
727
|
+
return res.status(400).json({ error: `Upsell not possible from ${from} to ${to}` });
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
const index = checkoutSession.line_items.findIndex((x) => x.price_id === from.id);
|
|
731
|
+
if (index === -1) {
|
|
732
|
+
return res.status(400).json({ error: 'Upsell from not exist in checkoutSession line items' });
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
const items = cloneDeep(checkoutSession.line_items);
|
|
736
|
+
items[index] = merge(items[index], { upsell_price_id: to.id });
|
|
737
|
+
await checkoutSession.update({ line_items: items });
|
|
738
|
+
logger.info('CheckoutSession updated on upsell', { id: req.params.id, from: from.id, to: to.id });
|
|
739
|
+
|
|
740
|
+
// recalculate amount
|
|
741
|
+
await checkoutSession.update(await getCheckoutSessionAmounts(checkoutSession));
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const items = await Price.expand(checkoutSession.line_items, { upsell: true });
|
|
745
|
+
res.json({ ...checkoutSession.toJSON(), line_items: items });
|
|
746
|
+
} catch (err) {
|
|
747
|
+
console.error(err);
|
|
748
|
+
res.status(500).json({ error: err.message });
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
router.put('/:id/downsell', user, async (req, res) => {
|
|
753
|
+
try {
|
|
754
|
+
// validate session
|
|
755
|
+
const checkoutSession = await CheckoutSession.findByPk(req.params.id);
|
|
756
|
+
if (!checkoutSession) {
|
|
757
|
+
return res.status(404).json({ error: 'Checkout session not found' });
|
|
758
|
+
}
|
|
759
|
+
if (checkoutSession.status === 'complete') {
|
|
760
|
+
return res.status(403).json({ error: 'Checkout session completed' });
|
|
761
|
+
}
|
|
762
|
+
if (checkoutSession.status === 'expired') {
|
|
763
|
+
return res.status(403).json({ error: 'Checkout session expired' });
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// validate from
|
|
767
|
+
const from = await Price.findByPk(req.body.from);
|
|
768
|
+
if (!from) {
|
|
769
|
+
return res.status(400).json({ error: 'Upsell from price not found' });
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
if (checkoutSession.line_items) {
|
|
773
|
+
const index = checkoutSession.line_items.findIndex((x) => x.upsell_price_id === from.id);
|
|
774
|
+
if (index === -1) {
|
|
775
|
+
return res.status(400).json({ error: 'Upsell not configured for checkout session' });
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const items = cloneDeep(checkoutSession.line_items);
|
|
779
|
+
items[index] = merge(items[index], { upsell_price_id: '' });
|
|
780
|
+
await checkoutSession.update({ line_items: items });
|
|
781
|
+
logger.info('CheckoutSession updated on downsell', { id: req.params.id, from: from.id });
|
|
782
|
+
|
|
783
|
+
// recalculate amount
|
|
784
|
+
await checkoutSession.update(await getCheckoutSessionAmounts(checkoutSession));
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
const items = await Price.expand(checkoutSession.line_items, { upsell: true });
|
|
788
|
+
res.json({ ...checkoutSession.toJSON(), line_items: items });
|
|
789
|
+
} catch (err) {
|
|
790
|
+
console.error(err);
|
|
791
|
+
res.status(500).json({ error: err.message });
|
|
792
|
+
}
|
|
793
|
+
});
|
|
794
|
+
|
|
654
795
|
// eslint-disable-next-line consistent-return
|
|
655
796
|
router.put('/:id/expire', auth, async (req, res) => {
|
|
656
797
|
const doc = await CheckoutSession.findByPk(req.params.id);
|
|
@@ -6,6 +6,7 @@ import { paymentQueue } from '../../jobs/payment';
|
|
|
6
6
|
import type { CallbackArgs } from '../../libs/auth';
|
|
7
7
|
import { wallet } from '../../libs/auth';
|
|
8
8
|
import dayjs from '../../libs/dayjs';
|
|
9
|
+
import { getTxMetadata } from '../../libs/util';
|
|
9
10
|
import { ensureInvoiceForCollect, getAuthPrincipalClaim } from './shared';
|
|
10
11
|
|
|
11
12
|
// Used to collect an open invoice failed to collect automatically
|
|
@@ -26,15 +27,10 @@ export default {
|
|
|
26
27
|
// @ts-ignore
|
|
27
28
|
const itx: TransferV3Tx = {
|
|
28
29
|
outputs: [{ owner: wallet.address, tokens, assets: [] }],
|
|
29
|
-
data: {
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
appId: wallet.address,
|
|
34
|
-
paymentIntentId: paymentIntent.id,
|
|
35
|
-
invoiceId,
|
|
36
|
-
},
|
|
37
|
-
},
|
|
30
|
+
data: getTxMetadata({
|
|
31
|
+
paymentIntentId: paymentIntent.id,
|
|
32
|
+
invoiceId,
|
|
33
|
+
}),
|
|
38
34
|
};
|
|
39
35
|
|
|
40
36
|
if (paymentMethod.type === 'arcblock') {
|
|
@@ -4,6 +4,7 @@ import { fromAddress } from '@ocap/wallet';
|
|
|
4
4
|
import type { CallbackArgs } from '../../libs/auth';
|
|
5
5
|
import { wallet } from '../../libs/auth';
|
|
6
6
|
import dayjs from '../../libs/dayjs';
|
|
7
|
+
import { getTxMetadata } from '../../libs/util';
|
|
7
8
|
import { ensureInvoiceForCheckout, ensurePaymentIntent, getAuthPrincipalClaim } from './shared';
|
|
8
9
|
|
|
9
10
|
export default {
|
|
@@ -27,15 +28,10 @@ export default {
|
|
|
27
28
|
// @ts-ignore
|
|
28
29
|
const itx: TransferV3Tx = {
|
|
29
30
|
outputs: [{ owner: wallet.address, tokens, assets: [] }],
|
|
30
|
-
data: {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
appId: wallet.address,
|
|
35
|
-
paymentIntentId: paymentIntent.id,
|
|
36
|
-
checkoutSessionId,
|
|
37
|
-
},
|
|
38
|
-
},
|
|
31
|
+
data: getTxMetadata({
|
|
32
|
+
paymentIntentId: paymentIntent.id,
|
|
33
|
+
checkoutSessionId,
|
|
34
|
+
}),
|
|
39
35
|
};
|
|
40
36
|
|
|
41
37
|
return {
|
|
@@ -6,6 +6,9 @@ import { fromPublicKey } from '@ocap/wallet';
|
|
|
6
6
|
import { subscriptionQueue } from '../../jobs/subscription';
|
|
7
7
|
import type { CallbackArgs } from '../../libs/auth';
|
|
8
8
|
import { wallet } from '../../libs/auth';
|
|
9
|
+
import { getFastCheckoutAmount } from '../../libs/session';
|
|
10
|
+
import { getTxMetadata } from '../../libs/util';
|
|
11
|
+
import type { TLineItemExpanded } from '../../store/models';
|
|
9
12
|
import { ensureSetupIntent, getAuthPrincipalClaim } from './shared';
|
|
10
13
|
|
|
11
14
|
export default {
|
|
@@ -19,12 +22,22 @@ export default {
|
|
|
19
22
|
},
|
|
20
23
|
onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
|
|
21
24
|
const { checkoutSessionId } = extraParams;
|
|
22
|
-
const { paymentMethod, subscription } = await ensureSetupIntent(
|
|
25
|
+
const { paymentMethod, paymentCurrency, checkoutSession, subscription } = await ensureSetupIntent(
|
|
26
|
+
checkoutSessionId,
|
|
27
|
+
userDid
|
|
28
|
+
);
|
|
23
29
|
if (!subscription) {
|
|
24
30
|
throw new Error('Subscription for checkoutSession not found');
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
if (paymentMethod.type === 'arcblock') {
|
|
34
|
+
const amount = getFastCheckoutAmount(
|
|
35
|
+
checkoutSession.line_items as TLineItemExpanded[],
|
|
36
|
+
checkoutSession.mode,
|
|
37
|
+
paymentCurrency,
|
|
38
|
+
!!checkoutSession.subscription_data?.trial_period_days
|
|
39
|
+
);
|
|
40
|
+
|
|
28
41
|
return {
|
|
29
42
|
signature: {
|
|
30
43
|
type: 'DelegateTx',
|
|
@@ -35,17 +48,16 @@ export default {
|
|
|
35
48
|
address: toDelegateAddress(userDid, wallet.address),
|
|
36
49
|
to: wallet.address,
|
|
37
50
|
ops: [{ typeUrl: 'fg:t:transfer_v2', rules: [] }],
|
|
38
|
-
data: {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
appId: wallet.address,
|
|
43
|
-
subscriptionId: subscription.id,
|
|
44
|
-
checkoutSessionId,
|
|
45
|
-
},
|
|
46
|
-
},
|
|
51
|
+
data: getTxMetadata({
|
|
52
|
+
subscriptionId: subscription.id,
|
|
53
|
+
checkoutSessionId,
|
|
54
|
+
}),
|
|
47
55
|
},
|
|
48
56
|
},
|
|
57
|
+
nonce: checkoutSessionId,
|
|
58
|
+
requirement: {
|
|
59
|
+
tokens: [{ address: paymentCurrency.contract as string, value: amount }],
|
|
60
|
+
},
|
|
49
61
|
chainInfo: {
|
|
50
62
|
host: paymentMethod.settings?.arcblock?.api_host as string,
|
|
51
63
|
id: paymentMethod.settings?.arcblock?.chain_id as string,
|
|
@@ -3,7 +3,8 @@ import { BN } from '@ocap/util';
|
|
|
3
3
|
import { blocklet } from '../../libs/auth';
|
|
4
4
|
import dayjs from '../../libs/dayjs';
|
|
5
5
|
import logger from '../../libs/logger';
|
|
6
|
-
import {
|
|
6
|
+
import { getStatementDescriptor } from '../../libs/session';
|
|
7
|
+
import type { TLineItemExpanded } from '../../store/models';
|
|
7
8
|
import { CheckoutSession } from '../../store/models/checkout-session';
|
|
8
9
|
import { Customer } from '../../store/models/customer';
|
|
9
10
|
import { Invoice } from '../../store/models/invoice';
|
|
@@ -110,7 +111,7 @@ export async function ensurePaymentIntent(checkoutSessionId: string, userDid?: s
|
|
|
110
111
|
throw new Error(`Payment method ${paymentMethod.type} should not be here`);
|
|
111
112
|
}
|
|
112
113
|
|
|
113
|
-
checkoutSession.line_items = await Price.expand(checkoutSession.line_items
|
|
114
|
+
checkoutSession.line_items = await Price.expand(checkoutSession.line_items);
|
|
114
115
|
|
|
115
116
|
return {
|
|
116
117
|
checkoutSession,
|
|
@@ -192,7 +193,7 @@ export async function ensureSetupIntent(checkoutSessionId: string, userDid?: str
|
|
|
192
193
|
throw new Error(`Payment method ${paymentMethod.type} should not be here`);
|
|
193
194
|
}
|
|
194
195
|
|
|
195
|
-
checkoutSession.line_items = await Price.expand(checkoutSession.line_items
|
|
196
|
+
checkoutSession.line_items = await Price.expand(checkoutSession.line_items);
|
|
196
197
|
|
|
197
198
|
return {
|
|
198
199
|
checkoutSession,
|
|
@@ -311,15 +312,16 @@ export async function ensureInvoiceForCheckout({
|
|
|
311
312
|
const subscriptionItems = subscription
|
|
312
313
|
? await SubscriptionItem.findAll({ where: { subscription_id: subscription?.id } })
|
|
313
314
|
: [];
|
|
314
|
-
const lineItems = await Price.expand(checkoutSession.line_items, true);
|
|
315
|
+
const lineItems = await Price.expand(checkoutSession.line_items, { product: true });
|
|
315
316
|
|
|
316
317
|
const trailing = !!checkoutSession.subscription_data?.trial_period_days;
|
|
317
318
|
const getLineSetup = (x: TLineItemExpanded) => {
|
|
318
|
-
|
|
319
|
+
const price = x.upsell_price || x.price;
|
|
320
|
+
if (price.type === 'recurring' && trailing) {
|
|
319
321
|
return {
|
|
320
322
|
amount: '0',
|
|
321
323
|
// @ts-ignore
|
|
322
|
-
description: trailing ? `${
|
|
324
|
+
description: trailing ? `${price.product.name} (trailing)` : price.product.name,
|
|
323
325
|
period: {
|
|
324
326
|
start: subscription?.current_period_start as number,
|
|
325
327
|
end: subscription?.current_period_end as number,
|
|
@@ -328,9 +330,9 @@ export async function ensureInvoiceForCheckout({
|
|
|
328
330
|
}
|
|
329
331
|
|
|
330
332
|
return {
|
|
331
|
-
amount: new BN(
|
|
333
|
+
amount: new BN(price.unit_amount).mul(new BN(x.quantity)).toString(),
|
|
332
334
|
// @ts-ignore
|
|
333
|
-
description:
|
|
335
|
+
description: price.product.name,
|
|
334
336
|
period: undefined,
|
|
335
337
|
};
|
|
336
338
|
};
|
|
@@ -338,9 +340,10 @@ export async function ensureInvoiceForCheckout({
|
|
|
338
340
|
const items = await Promise.all(
|
|
339
341
|
lineItems.map((x: TLineItemExpanded) => {
|
|
340
342
|
const setup = getLineSetup(x);
|
|
343
|
+
const price = x.upsell_price || x.price;
|
|
341
344
|
let { quantity } = x;
|
|
342
|
-
if (
|
|
343
|
-
if (
|
|
345
|
+
if (price.type === 'recurring') {
|
|
346
|
+
if (price.recurring?.usage_type === 'metered') {
|
|
344
347
|
quantity = 0;
|
|
345
348
|
}
|
|
346
349
|
if (trailing) {
|
|
@@ -8,6 +8,9 @@ import { invoiceQueue } from '../../jobs/invoice';
|
|
|
8
8
|
import { subscriptionQueue } from '../../jobs/subscription';
|
|
9
9
|
import type { CallbackArgs } from '../../libs/auth';
|
|
10
10
|
import { wallet } from '../../libs/auth';
|
|
11
|
+
import { getFastCheckoutAmount } from '../../libs/session';
|
|
12
|
+
import { getTxMetadata } from '../../libs/util';
|
|
13
|
+
import type { TLineItemExpanded } from '../../store/models';
|
|
11
14
|
import { ensureInvoiceForCheckout, ensurePaymentIntent, getAuthPrincipalClaim } from './shared';
|
|
12
15
|
|
|
13
16
|
export default {
|
|
@@ -30,16 +33,12 @@ export default {
|
|
|
30
33
|
}
|
|
31
34
|
|
|
32
35
|
if (paymentMethod.type === 'arcblock') {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
`Your account ${userDid} does not have enough ${paymentCurrency.symbol} to complete this subscription`
|
|
40
|
-
);
|
|
41
|
-
}
|
|
42
|
-
}
|
|
36
|
+
const amount = getFastCheckoutAmount(
|
|
37
|
+
checkoutSession.line_items as TLineItemExpanded[],
|
|
38
|
+
checkoutSession.mode,
|
|
39
|
+
paymentCurrency,
|
|
40
|
+
!!checkoutSession.subscription_data?.trial_period_days
|
|
41
|
+
);
|
|
43
42
|
|
|
44
43
|
return {
|
|
45
44
|
signature: {
|
|
@@ -51,17 +50,16 @@ export default {
|
|
|
51
50
|
address: toDelegateAddress(userDid, wallet.address),
|
|
52
51
|
to: wallet.address,
|
|
53
52
|
ops: [{ typeUrl: 'fg:t:transfer_v2', rules: [] }],
|
|
54
|
-
data: {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
appId: wallet.address,
|
|
59
|
-
subscriptionId: subscription.id,
|
|
60
|
-
checkoutSessionId,
|
|
61
|
-
},
|
|
62
|
-
},
|
|
53
|
+
data: getTxMetadata({
|
|
54
|
+
subscriptionId: subscription.id,
|
|
55
|
+
checkoutSessionId,
|
|
56
|
+
}),
|
|
63
57
|
},
|
|
64
58
|
},
|
|
59
|
+
nonce: checkoutSessionId,
|
|
60
|
+
requirement: {
|
|
61
|
+
tokens: [{ address: paymentCurrency.contract as string, value: amount }],
|
|
62
|
+
},
|
|
65
63
|
chainInfo: {
|
|
66
64
|
host: paymentMethod.settings?.arcblock?.api_host as string,
|
|
67
65
|
id: paymentMethod.settings?.arcblock?.chain_id as string,
|
|
@@ -74,7 +72,7 @@ export default {
|
|
|
74
72
|
},
|
|
75
73
|
onAuth: async ({ userDid, userPk, claims, request, extraParams }: CallbackArgs) => {
|
|
76
74
|
const { checkoutSessionId } = extraParams;
|
|
77
|
-
const { checkoutSession, customer, paymentMethod, subscription } = await ensurePaymentIntent(
|
|
75
|
+
const { checkoutSession, customer, paymentMethod, paymentCurrency, subscription } = await ensurePaymentIntent(
|
|
78
76
|
checkoutSessionId,
|
|
79
77
|
userDid
|
|
80
78
|
);
|
|
@@ -83,6 +81,17 @@ export default {
|
|
|
83
81
|
throw new Error('Subscription for checkoutSession not found');
|
|
84
82
|
}
|
|
85
83
|
|
|
84
|
+
if (checkoutSession.amount_total > '0') {
|
|
85
|
+
const client = paymentMethod.getOcapClient();
|
|
86
|
+
const result = await client.getAccountTokens({ address: userDid, token: paymentCurrency.contract });
|
|
87
|
+
const balance = result.tokens[0]?.balance || '0';
|
|
88
|
+
if (new BN(balance).lt(new BN(checkoutSession.amount_total))) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`Your account ${userDid} does not have enough ${paymentCurrency.symbol} to complete this subscription`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
86
95
|
if (paymentMethod.type === 'arcblock') {
|
|
87
96
|
await subscription.update({
|
|
88
97
|
payment_settings: {
|
|
@@ -3,6 +3,7 @@ import { Router } from 'express';
|
|
|
3
3
|
import Joi from 'joi';
|
|
4
4
|
import type { WhereOptions } from 'sequelize';
|
|
5
5
|
|
|
6
|
+
import { syncStripeInvoice } from '../integrations/stripe/handlers/invoice';
|
|
6
7
|
import { authenticate } from '../libs/security';
|
|
7
8
|
import { expandLineItems } from '../libs/session';
|
|
8
9
|
import { Customer } from '../store/models/customer';
|
|
@@ -102,7 +103,6 @@ router.get('/', authMine, async (req, res) => {
|
|
|
102
103
|
}
|
|
103
104
|
});
|
|
104
105
|
|
|
105
|
-
// FIXME: exclude some sensitive fields from PaymentMethod
|
|
106
106
|
router.get('/:id', authPortal, async (req, res) => {
|
|
107
107
|
try {
|
|
108
108
|
const doc = await Invoice.findOne({
|
|
@@ -118,6 +118,10 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
118
118
|
});
|
|
119
119
|
|
|
120
120
|
if (doc) {
|
|
121
|
+
if (doc.status !== 'paid' && doc.metadata?.stripe_id) {
|
|
122
|
+
await syncStripeInvoice(doc);
|
|
123
|
+
}
|
|
124
|
+
|
|
121
125
|
const json = doc.toJSON();
|
|
122
126
|
const products = (await Product.findAll()).map((x) => x.toJSON());
|
|
123
127
|
const prices = (await Price.findAll()).map((x) => x.toJSON());
|
|
@@ -3,6 +3,7 @@ import { Router } from 'express';
|
|
|
3
3
|
import Joi from 'joi';
|
|
4
4
|
import type { WhereOptions } from 'sequelize';
|
|
5
5
|
|
|
6
|
+
import { syncStripPayment } from '../integrations/stripe/handlers/payment-intent';
|
|
6
7
|
import { authenticate } from '../libs/security';
|
|
7
8
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
8
9
|
import { Customer } from '../store/models/customer';
|
|
@@ -98,7 +99,6 @@ router.get('/', authMine, async (req, res) => {
|
|
|
98
99
|
}
|
|
99
100
|
});
|
|
100
101
|
|
|
101
|
-
// FIXME: exclude some sensitive fields from PaymentMethod
|
|
102
102
|
router.get('/:id', authPortal, async (req, res) => {
|
|
103
103
|
try {
|
|
104
104
|
const doc = await PaymentIntent.findOne({
|
|
@@ -115,6 +115,10 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
115
115
|
let subscription;
|
|
116
116
|
|
|
117
117
|
if (doc) {
|
|
118
|
+
if (doc.status !== 'succeeded' && doc.metadata?.stripe_id) {
|
|
119
|
+
await syncStripPayment(doc);
|
|
120
|
+
}
|
|
121
|
+
|
|
118
122
|
checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: doc.id } });
|
|
119
123
|
invoice = await Invoice.findByPk(doc.invoice_id);
|
|
120
124
|
if (invoice && invoice.subscription_id) {
|