payment-kit 1.13.18 → 1.13.19
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/README.md +14 -0
- package/api/src/index.ts +17 -6
- package/api/src/integrations/stripe/handlers/index.ts +53 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +252 -0
- package/api/src/integrations/stripe/handlers/payment-intent.ts +172 -0
- package/api/src/integrations/stripe/handlers/setup-intent.ts +42 -0
- package/api/src/integrations/stripe/handlers/subscription.ts +61 -0
- package/api/src/integrations/stripe/resource.ts +317 -0
- package/api/src/integrations/stripe/setup.ts +50 -0
- package/api/src/jobs/invoice.ts +11 -0
- package/api/src/jobs/payment.ts +15 -7
- package/api/src/jobs/subscription.ts +18 -2
- package/api/src/libs/session.ts +104 -8
- package/api/src/libs/util.ts +47 -1
- package/api/src/routes/checkout-sessions.ts +134 -27
- package/api/src/routes/connect/collect.ts +12 -4
- package/api/src/routes/connect/pay.ts +30 -20
- package/api/src/routes/connect/setup.ts +12 -4
- package/api/src/routes/connect/shared.ts +28 -4
- package/api/src/routes/connect/subscribe.ts +12 -5
- package/api/src/routes/customers.ts +5 -5
- package/api/src/routes/events.ts +9 -6
- package/api/src/routes/index.ts +2 -0
- package/api/src/routes/integrations/stripe.ts +64 -0
- package/api/src/routes/invoices.ts +19 -9
- package/api/src/routes/payment-intents.ts +19 -9
- package/api/src/routes/payment-links.ts +57 -15
- package/api/src/routes/payment-methods.ts +98 -1
- package/api/src/routes/prices.ts +71 -14
- package/api/src/routes/products.ts +79 -22
- package/api/src/routes/settings.ts +10 -11
- package/api/src/routes/subscription-items.ts +5 -5
- package/api/src/routes/subscriptions.ts +61 -10
- package/api/src/routes/usage-records.ts +52 -18
- package/api/src/routes/webhook-attempts.ts +5 -5
- package/api/src/routes/webhook-endpoints.ts +5 -5
- package/api/src/store/migrations/20230905-genesis.ts +2 -2
- package/api/src/store/migrations/20230911-seeding.ts +4 -3
- package/api/src/store/models/checkout-session.ts +15 -7
- package/api/src/store/models/index.ts +31 -7
- package/api/src/store/models/invoice.ts +1 -1
- package/api/src/store/models/payment-intent.ts +2 -5
- package/api/src/store/models/payment-link.ts +1 -1
- package/api/src/store/models/payment-method.ts +54 -33
- package/api/src/store/models/price.ts +52 -17
- package/api/src/store/models/product.ts +0 -3
- package/api/src/store/models/subscription.ts +3 -5
- package/api/src/store/models/types.ts +56 -2
- package/api/third.d.ts +2 -0
- package/blocklet.yml +1 -1
- package/package.json +13 -6
- package/public/currencies/dai.png +0 -0
- package/public/currencies/dollar.png +0 -0
- package/public/currencies/usdc.png +0 -0
- package/public/currencies/usdt.png +0 -0
- package/public/methods/arcblock.png +0 -0
- package/public/methods/binance.png +0 -0
- package/public/methods/coinbase.png +0 -0
- package/public/methods/ethereum.jpg +0 -0
- package/public/methods/stripe.png +0 -0
- package/src/components/checkout/form/address.tsx +86 -10
- package/src/components/checkout/form/index.tsx +169 -83
- package/src/components/checkout/form/phone.tsx +96 -0
- package/src/components/checkout/form/stripe.tsx +195 -0
- package/src/components/checkout/pay.tsx +115 -34
- package/src/components/checkout/product-item.tsx +4 -3
- package/src/components/checkout/summary.tsx +5 -4
- package/src/components/drawer-form.tsx +4 -4
- package/src/components/input.tsx +22 -4
- package/src/components/invoice/table.tsx +8 -3
- package/src/components/payment-link/before-pay.tsx +11 -6
- package/src/components/payment-link/chrome.tsx +13 -0
- package/src/components/payment-link/preview.tsx +31 -0
- package/src/components/payment-link/product-select.tsx +8 -3
- package/src/components/payment-method/arcblock.tsx +53 -0
- package/src/components/payment-method/bitcoin.tsx +53 -0
- package/src/components/payment-method/ethereum.tsx +53 -0
- package/src/components/payment-method/form.tsx +54 -0
- package/src/components/payment-method/stripe.tsx +45 -0
- package/src/components/portal/invoice/list.tsx +1 -1
- package/src/components/portal/subscription/list.tsx +1 -1
- package/src/components/price/currency-select.tsx +53 -0
- package/src/components/price/form.tsx +118 -24
- package/src/components/product/add-price.tsx +1 -1
- package/src/components/product/edit-price.tsx +6 -2
- package/src/components/subscription/items/index.tsx +7 -6
- package/src/components/subscription/items/usage-records.tsx +98 -0
- package/src/components/subscription/list.tsx +3 -2
- package/src/components/subscription/status.tsx +68 -0
- package/src/contexts/settings.tsx +2 -2
- package/src/env.d.ts +2 -0
- package/src/libs/util.ts +116 -21
- package/src/locales/en.tsx +71 -3
- package/src/pages/admin/billing/invoices/detail.tsx +5 -2
- package/src/pages/admin/billing/subscriptions/detail.tsx +6 -6
- package/src/pages/admin/customers/customers/detail.tsx +13 -1
- package/src/pages/admin/payments/intents/detail.tsx +8 -3
- package/src/pages/admin/payments/links/create.tsx +23 -3
- package/src/pages/admin/payments/links/detail.tsx +13 -26
- package/src/pages/admin/products/prices/detail.tsx +55 -11
- package/src/pages/admin/products/prices/list.tsx +7 -1
- package/src/pages/admin/products/products/create.tsx +1 -1
- package/src/pages/admin/products/products/detail.tsx +14 -7
- package/src/pages/admin/settings/index.tsx +16 -6
- package/src/pages/admin/settings/payment-methods/create.tsx +81 -0
- package/src/pages/admin/settings/{payment-methods.tsx → payment-methods/index.tsx} +9 -6
- package/src/pages/checkout/pay.tsx +3 -1
- package/src/pages/customer/index.tsx +12 -1
- package/public/.gitkeep +0 -0
package/README.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
1
1
|
# Payment Kit
|
|
2
2
|
|
|
3
3
|
The decentralized stripe for blocklet platform.
|
|
4
|
+
|
|
5
|
+
## Contribution
|
|
6
|
+
|
|
7
|
+
### Development
|
|
8
|
+
|
|
9
|
+
1. clone the repo
|
|
10
|
+
2. run `make build`
|
|
11
|
+
3. run `cd blocklets/core && blocklet dev`
|
|
12
|
+
|
|
13
|
+
### Debug Stripe
|
|
14
|
+
|
|
15
|
+
1. Install and login with instructions from: https://stripe.com/docs/stripe-cli
|
|
16
|
+
2. Start your local payment-kit server, get it's port
|
|
17
|
+
3. Run `stripe listen --forward-to http://127.0.0.1:8188/api/integrations/stripe/webhook --log-level=debug --latest`
|
package/api/src/index.ts
CHANGED
|
@@ -9,6 +9,7 @@ import dotenv from 'dotenv-flow';
|
|
|
9
9
|
import express, { ErrorRequestHandler, Request, Response } from 'express';
|
|
10
10
|
import morgan from 'morgan';
|
|
11
11
|
|
|
12
|
+
import { ensureWebhookRegistered } from './integrations/stripe/setup';
|
|
12
13
|
import { startEventQueue } from './jobs/event';
|
|
13
14
|
import { startInvoiceQueue } from './jobs/invoice';
|
|
14
15
|
import { startPaymentQueue } from './jobs/payment';
|
|
@@ -26,15 +27,19 @@ import { sequelize } from './store/sequelize';
|
|
|
26
27
|
|
|
27
28
|
dotenv.config();
|
|
28
29
|
|
|
29
|
-
const { name, version } = require('../../package.json');
|
|
30
|
-
|
|
31
30
|
initialize(sequelize);
|
|
32
31
|
|
|
33
32
|
export const app = express();
|
|
34
33
|
|
|
35
34
|
app.set('trust proxy', true);
|
|
36
35
|
app.use(cookieParser());
|
|
37
|
-
app.use(
|
|
36
|
+
app.use((req, res, next) => {
|
|
37
|
+
if (req.originalUrl.startsWith('/api/integrations/stripe/webhook')) {
|
|
38
|
+
next();
|
|
39
|
+
} else {
|
|
40
|
+
express.json({ limit: '1 mb' })(req, res, next);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
38
43
|
app.use(express.urlencoded({ extended: true, limit: '1 mb' }));
|
|
39
44
|
app.use(cors());
|
|
40
45
|
app.use(ensureI18n());
|
|
@@ -73,9 +78,13 @@ if (isProduction) {
|
|
|
73
78
|
app.use(fallback('index.html', { root: staticDir }));
|
|
74
79
|
|
|
75
80
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
76
|
-
app.use(<ErrorRequestHandler>((err,
|
|
81
|
+
app.use(<ErrorRequestHandler>((err, req, res, _next) => {
|
|
77
82
|
logger.error(err.stack);
|
|
78
|
-
|
|
83
|
+
if (req.accepts('json')) {
|
|
84
|
+
res.status(500).send({ error: err.message });
|
|
85
|
+
} else {
|
|
86
|
+
res.status(500).send('Something broke!');
|
|
87
|
+
}
|
|
79
88
|
}));
|
|
80
89
|
}
|
|
81
90
|
|
|
@@ -83,10 +92,12 @@ const port = parseInt(process.env.BLOCKLET_PORT!, 10);
|
|
|
83
92
|
|
|
84
93
|
export const server = app.listen(port, (err?: any) => {
|
|
85
94
|
if (err) throw err;
|
|
86
|
-
logger.info(`>
|
|
95
|
+
logger.info(`> payment-kit ready on ${port}`);
|
|
87
96
|
|
|
88
97
|
startPaymentQueue().then(() => logger.info('payment queue started'));
|
|
89
98
|
startInvoiceQueue().then(() => logger.info('invoice queue started'));
|
|
90
99
|
startSubscriptionQueue().then(() => logger.info('subscription queue started'));
|
|
91
100
|
startEventQueue().then(() => logger.info('event queue started'));
|
|
101
|
+
|
|
102
|
+
ensureWebhookRegistered().catch(console.error);
|
|
92
103
|
});
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type Stripe from 'stripe';
|
|
2
|
+
|
|
3
|
+
import { handleInvoiceEvent } from './invoice';
|
|
4
|
+
import { handlePaymentIntentEvent } from './payment-intent';
|
|
5
|
+
import { handleSetupIntentEvent } from './setup-intent';
|
|
6
|
+
import { handleSubscriptionEvent } from './subscription';
|
|
7
|
+
|
|
8
|
+
export default function handleStripeEvent(event: any, client: Stripe) {
|
|
9
|
+
switch (event.type) {
|
|
10
|
+
case 'payment_intent.canceled':
|
|
11
|
+
case 'payment_intent.created':
|
|
12
|
+
case 'payment_intent.partially_funded':
|
|
13
|
+
case 'payment_intent.payment_failed':
|
|
14
|
+
case 'payment_intent.processing':
|
|
15
|
+
case 'payment_intent.requires_action':
|
|
16
|
+
case 'payment_intent.succeeded':
|
|
17
|
+
return handlePaymentIntentEvent(event, client);
|
|
18
|
+
|
|
19
|
+
// case 'setup_intent.created':
|
|
20
|
+
case 'setup_intent.canceled':
|
|
21
|
+
case 'setup_intent.requires_action':
|
|
22
|
+
case 'setup_intent.setup_failed':
|
|
23
|
+
case 'setup_intent.succeeded':
|
|
24
|
+
return handleSetupIntentEvent(event, client);
|
|
25
|
+
|
|
26
|
+
case 'customer.subscription.deleted':
|
|
27
|
+
case 'customer.subscription.paused':
|
|
28
|
+
case 'customer.subscription.pending_update_applied':
|
|
29
|
+
case 'customer.subscription.pending_update_expired':
|
|
30
|
+
case 'customer.subscription.resumed':
|
|
31
|
+
case 'customer.subscription.trial_will_end':
|
|
32
|
+
case 'customer.subscription.updated':
|
|
33
|
+
return handleSubscriptionEvent(event, client);
|
|
34
|
+
|
|
35
|
+
case 'invoice.created':
|
|
36
|
+
case 'invoice.deleted':
|
|
37
|
+
case 'invoice.finalization_failed':
|
|
38
|
+
case 'invoice.finalized':
|
|
39
|
+
case 'invoice.marked_uncollectible':
|
|
40
|
+
case 'invoice.paid':
|
|
41
|
+
case 'invoice.payment_action_required':
|
|
42
|
+
case 'invoice.payment_failed':
|
|
43
|
+
case 'invoice.payment_succeeded':
|
|
44
|
+
case 'invoice.sent':
|
|
45
|
+
case 'invoice.upcoming':
|
|
46
|
+
case 'invoice.updated':
|
|
47
|
+
case 'invoice.voided':
|
|
48
|
+
return handleInvoiceEvent(event, client);
|
|
49
|
+
|
|
50
|
+
default:
|
|
51
|
+
return Promise.resolve(true);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import env from '@blocklet/sdk/lib/env';
|
|
2
|
+
import pick from 'lodash/pick';
|
|
3
|
+
import pWaitFor from 'p-wait-for';
|
|
4
|
+
import type Stripe from 'stripe';
|
|
5
|
+
|
|
6
|
+
import logger from '../../../libs/logger';
|
|
7
|
+
import {
|
|
8
|
+
CheckoutSession,
|
|
9
|
+
Customer,
|
|
10
|
+
Invoice,
|
|
11
|
+
InvoiceItem,
|
|
12
|
+
Subscription,
|
|
13
|
+
SubscriptionItem,
|
|
14
|
+
TEventExpanded,
|
|
15
|
+
} from '../../../store/models';
|
|
16
|
+
|
|
17
|
+
export async function handleStripeInvoicePaid(invoice: Invoice, event: TEventExpanded) {
|
|
18
|
+
logger.info('invoice paid on stripe event', { locale: invoice.id });
|
|
19
|
+
await invoice.update({
|
|
20
|
+
status: 'paid',
|
|
21
|
+
...pick(event.data.object, [
|
|
22
|
+
'paid',
|
|
23
|
+
'paid_out_of_band',
|
|
24
|
+
'amount_due',
|
|
25
|
+
'amount_paid',
|
|
26
|
+
'amount_remaining',
|
|
27
|
+
'status_transitions',
|
|
28
|
+
]),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function handleStripeInvoiceCreated(event: TEventExpanded, client: Stripe) {
|
|
33
|
+
if (['invoice.created', 'payment_intent.created'].includes(event.type) === false) {
|
|
34
|
+
logger.warn('abort because event type not expected', { id: event.id, type: event.type });
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const stripeInvoiceId = event.type === 'invoice.created' ? event.data.object.id : event.data.object.invoice;
|
|
39
|
+
const stripeInvoice = await client.invoices.retrieve(stripeInvoiceId);
|
|
40
|
+
if (!stripeInvoice) {
|
|
41
|
+
logger.warn('abort because stripe invoice not found', { id: event.id, type: event.type });
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
if (!stripeInvoice.subscription) {
|
|
45
|
+
logger.warn('abort because invoice have no subscription', { id: event.id, type: event.type });
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const stripeSubscription = await client.subscriptions.retrieve(stripeInvoice.subscription as string);
|
|
50
|
+
if (!stripeSubscription) {
|
|
51
|
+
logger.warn('abort because subscription not found', { id: event.id, type: event.type });
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (stripeSubscription.metadata?.appPid !== env.appPid) {
|
|
55
|
+
logger.warn('abort because subscription not interested', { id: event.id, type: event.type });
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const subscription = await Subscription.findByPk(stripeSubscription.metadata.id);
|
|
60
|
+
if (!subscription) {
|
|
61
|
+
logger.warn('abort because local subscription not exist', { id: event.id, type: event.type });
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
logger.info('valid event for subscription detected', {
|
|
66
|
+
id: event.id,
|
|
67
|
+
type: event.type,
|
|
68
|
+
stripeInvoiceId: stripeInvoice.id,
|
|
69
|
+
stripeSubscriptionId: stripeSubscription.id,
|
|
70
|
+
localSubscriptionId: subscription.id,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const customer = await Customer.findByPk(subscription.customer_id);
|
|
74
|
+
const checkoutSession = await CheckoutSession.findOne({ where: { subscription_id: subscription.id } });
|
|
75
|
+
|
|
76
|
+
// create stripe invoice
|
|
77
|
+
let invoice = await Invoice.findOne({ where: { 'metadata.stripe_id': stripeInvoice.id } });
|
|
78
|
+
if (!invoice) {
|
|
79
|
+
invoice = await Invoice.create({
|
|
80
|
+
// @ts-ignore
|
|
81
|
+
number: await customer.getInvoiceNumber(),
|
|
82
|
+
...pick(stripeInvoice, [
|
|
83
|
+
'amount_due',
|
|
84
|
+
'amount_paid',
|
|
85
|
+
'amount_remaining',
|
|
86
|
+
'amount_shipping',
|
|
87
|
+
'attempt_count',
|
|
88
|
+
'attempted',
|
|
89
|
+
'auto_advance',
|
|
90
|
+
'billing_reason',
|
|
91
|
+
'collection_method',
|
|
92
|
+
'custom_fields',
|
|
93
|
+
'customer_address',
|
|
94
|
+
'customer_email',
|
|
95
|
+
'customer_name',
|
|
96
|
+
'customer_phone',
|
|
97
|
+
'description',
|
|
98
|
+
'discounts',
|
|
99
|
+
'due_date',
|
|
100
|
+
'effective_at',
|
|
101
|
+
'ending_balance',
|
|
102
|
+
'livemode',
|
|
103
|
+
'paid_out_of_band',
|
|
104
|
+
'paid',
|
|
105
|
+
'period_end',
|
|
106
|
+
'period_start',
|
|
107
|
+
'starting_balance',
|
|
108
|
+
'status_transitions',
|
|
109
|
+
'status',
|
|
110
|
+
'subtotal_excluding_tax',
|
|
111
|
+
'subtotal',
|
|
112
|
+
'tax',
|
|
113
|
+
'total_discount_amounts',
|
|
114
|
+
'total',
|
|
115
|
+
]),
|
|
116
|
+
|
|
117
|
+
currency_id: subscription.currency_id,
|
|
118
|
+
customer_id: subscription.customer_id,
|
|
119
|
+
default_payment_method_id: subscription.default_payment_method_id as string,
|
|
120
|
+
payment_intent_id: '',
|
|
121
|
+
subscription_id: subscription.id,
|
|
122
|
+
checkout_session_id: checkoutSession?.id,
|
|
123
|
+
statement_descriptor: stripeInvoice.statement_descriptor || '',
|
|
124
|
+
|
|
125
|
+
payment_settings: subscription.payment_settings,
|
|
126
|
+
metadata: {
|
|
127
|
+
stripe_id: stripeInvoice.id,
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
await client.invoices.update(stripeInvoice.id, { metadata: { appPid: env.appPid, id: invoice.id } });
|
|
131
|
+
logger.info('stripe invoice mirrored', { local: invoice.id, remote: stripeInvoice.id });
|
|
132
|
+
|
|
133
|
+
await Promise.all(
|
|
134
|
+
stripeInvoice.lines.data.map(async (line) => {
|
|
135
|
+
const subscriptionItem = line.subscription_item
|
|
136
|
+
? await SubscriptionItem.findOne({ where: { 'metadata.stripe_id': line.subscription_item } })
|
|
137
|
+
: null;
|
|
138
|
+
|
|
139
|
+
// @ts-ignore
|
|
140
|
+
const item = await InvoiceItem.create({
|
|
141
|
+
currency_id: subscription.currency_id,
|
|
142
|
+
customer_id: subscription.customer_id,
|
|
143
|
+
price_id: line.price?.metadata.id as string,
|
|
144
|
+
invoice_id: invoice?.id as string,
|
|
145
|
+
subscription_id: subscription.id,
|
|
146
|
+
subscription_item_id: subscriptionItem?.id,
|
|
147
|
+
...pick(line, [
|
|
148
|
+
'livemode',
|
|
149
|
+
'amount',
|
|
150
|
+
'quantity',
|
|
151
|
+
'description',
|
|
152
|
+
'period',
|
|
153
|
+
'discountable',
|
|
154
|
+
'discount_amounts',
|
|
155
|
+
'discounts',
|
|
156
|
+
'proration',
|
|
157
|
+
'proration_details',
|
|
158
|
+
]),
|
|
159
|
+
metadata: {
|
|
160
|
+
stripe_id: line.id,
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
logger.info('stripe invoice items mirrored', { local: item.id, remote: line.id });
|
|
165
|
+
return item;
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return { subscription, invoice, customer, checkoutSession };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const waitForStripeInvoiceMirrored = (stripeInvoiceId: string) => {
|
|
174
|
+
return pWaitFor(
|
|
175
|
+
async () => {
|
|
176
|
+
const invoice = await Invoice.findOne({ where: { 'metadata.stripe_id': stripeInvoiceId } });
|
|
177
|
+
return !!invoice;
|
|
178
|
+
},
|
|
179
|
+
{ interval: 1000, timeout: 20 * 1000 }
|
|
180
|
+
);
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export async function handleInvoiceEvent(event: TEventExpanded, client: Stripe) {
|
|
184
|
+
if (event.type === 'invoice.created') {
|
|
185
|
+
await handleStripeInvoiceCreated(event, client);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const localInvoiceId = event.data.object.metadata?.id;
|
|
190
|
+
if (!localInvoiceId) {
|
|
191
|
+
try {
|
|
192
|
+
await waitForStripeInvoiceMirrored(event.data.object.id);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
logger.error('wait for stripe invoice mirror error', { localInvoiceId, error: err });
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
logger.warn('local invoice id not found in strip event', { localInvoiceId });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const invoice = await Invoice.findByPk(localInvoiceId);
|
|
202
|
+
if (!invoice) {
|
|
203
|
+
logger.warn('local invoice not found', { localInvoiceId });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
logger.info('received invoice event', { id: event.id, type: event.type, localInvoiceId });
|
|
208
|
+
|
|
209
|
+
if (event.type === 'invoice.paid') {
|
|
210
|
+
await handleStripeInvoicePaid(invoice, event);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (event.type === 'invoice.finalized') {
|
|
215
|
+
await invoice.update({ status: 'finalized', status_transitions: event.data.object.status_transitions });
|
|
216
|
+
logger.info('invoice finalized on stripe event', { locale: invoice.id });
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (event.type === 'invoice.voided') {
|
|
221
|
+
await invoice.update({ status: 'void', status_transitions: event.data.object.status_transitions });
|
|
222
|
+
logger.info('invoice voided on stripe event', { locale: invoice.id });
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (event.type === 'invoice.marked_uncollectible') {
|
|
227
|
+
await invoice.update({
|
|
228
|
+
status: 'uncollectible',
|
|
229
|
+
status_transitions: event.data.object.status_transitions,
|
|
230
|
+
});
|
|
231
|
+
logger.info('invoice uncollectible on stripe event', { locale: invoice.id });
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// TODO: handle upcoming invoices?
|
|
236
|
+
if (event.type === 'invoice.upcoming') {
|
|
237
|
+
logger.info('received invoice upcoming event', event);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (event.type === 'invoice.finalization_failed') {
|
|
241
|
+
await invoice.update({
|
|
242
|
+
status: 'finalization_failed',
|
|
243
|
+
last_finalization_error: event.data.object.last_finalization_error,
|
|
244
|
+
});
|
|
245
|
+
logger.info('invoice finalization failed on stripe event', { locale: invoice.id });
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (event.type === 'invoice.payment_failed') {
|
|
249
|
+
await invoice.update({ status: 'payment_failed' });
|
|
250
|
+
logger.info('invoice payment failed on stripe event', { locale: invoice.id });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import env from '@blocklet/sdk/lib/env';
|
|
2
|
+
import merge from 'lodash/merge';
|
|
3
|
+
import pick from 'lodash/pick';
|
|
4
|
+
import pWaitFor from 'p-wait-for';
|
|
5
|
+
import type Stripe from 'stripe';
|
|
6
|
+
|
|
7
|
+
import dayjs from '../../../libs/dayjs';
|
|
8
|
+
import logger from '../../../libs/logger';
|
|
9
|
+
import { CheckoutSession, Invoice, PaymentIntent, TEventExpanded } from '../../../store/models';
|
|
10
|
+
import { handleStripeInvoiceCreated } from './invoice';
|
|
11
|
+
|
|
12
|
+
export async function handleStripePaymentSucceed(paymentIntent: PaymentIntent, event?: TEventExpanded) {
|
|
13
|
+
await paymentIntent.update({
|
|
14
|
+
status: 'succeeded',
|
|
15
|
+
amount_received: paymentIntent.amount,
|
|
16
|
+
payment_details: merge(
|
|
17
|
+
paymentIntent.metadata,
|
|
18
|
+
event ? { stripe: { payment_intent_id: event.data.object.id, customer_id: event.data.object.customer } } : {}
|
|
19
|
+
),
|
|
20
|
+
});
|
|
21
|
+
logger.info('payment intent succeeded on stripe event', { locale: paymentIntent.id });
|
|
22
|
+
|
|
23
|
+
const checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: paymentIntent.id } });
|
|
24
|
+
if (checkoutSession) {
|
|
25
|
+
await checkoutSession.update({
|
|
26
|
+
status: 'complete',
|
|
27
|
+
payment_status: 'paid',
|
|
28
|
+
payment_details: paymentIntent.payment_details,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (paymentIntent.invoice_id) {
|
|
33
|
+
const invoice = await Invoice.findByPk(paymentIntent.invoice_id);
|
|
34
|
+
if (invoice && invoice.status !== 'paid') {
|
|
35
|
+
await invoice.update({
|
|
36
|
+
paid: true,
|
|
37
|
+
status: 'paid',
|
|
38
|
+
amount_due: '0',
|
|
39
|
+
amount_paid: paymentIntent.amount,
|
|
40
|
+
amount_remaining: '0',
|
|
41
|
+
attempt_count: invoice.attempt_count + 1,
|
|
42
|
+
attempted: true,
|
|
43
|
+
status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function handleStripePaymentCreated(event: TEventExpanded, client: Stripe) {
|
|
50
|
+
logger.info('possible payment intent from subscription', { id: event.id, type: event.type });
|
|
51
|
+
|
|
52
|
+
const result = await handleStripeInvoiceCreated(event, client);
|
|
53
|
+
if (!result) {
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { invoice, checkoutSession } = result;
|
|
58
|
+
|
|
59
|
+
// create stripe intent
|
|
60
|
+
const stripeIntent = event.data.object;
|
|
61
|
+
let paymentIntent = await PaymentIntent.findOne({ where: { 'metadata.stripe_id': stripeIntent.id } });
|
|
62
|
+
if (!paymentIntent) {
|
|
63
|
+
// @ts-ignore
|
|
64
|
+
paymentIntent = await PaymentIntent.create({
|
|
65
|
+
customer_id: invoice.customer_id,
|
|
66
|
+
currency_id: invoice.currency_id,
|
|
67
|
+
payment_method_id: invoice.default_payment_method_id,
|
|
68
|
+
invoice_id: invoice.id,
|
|
69
|
+
payment_method_types: ['stripe'],
|
|
70
|
+
|
|
71
|
+
amount: String(stripeIntent.amount),
|
|
72
|
+
amount_received: '0',
|
|
73
|
+
amount_capturable: String(stripeIntent.amount_capturable),
|
|
74
|
+
amount_details: { tip: '0' },
|
|
75
|
+
|
|
76
|
+
...pick(stripeIntent, [
|
|
77
|
+
'livemode',
|
|
78
|
+
'description',
|
|
79
|
+
'status',
|
|
80
|
+
'confirmation_method',
|
|
81
|
+
'capture_method',
|
|
82
|
+
'receipt_email',
|
|
83
|
+
'statement_descriptor',
|
|
84
|
+
'statement_descriptor_suffix',
|
|
85
|
+
'setup_future_usage',
|
|
86
|
+
]),
|
|
87
|
+
|
|
88
|
+
metadata: {
|
|
89
|
+
stripe_id: stripeIntent.id,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
await client.paymentIntents.update(stripeIntent.id, { metadata: { appPid: env.appPid, id: paymentIntent.id } });
|
|
93
|
+
await invoice.update({ payment_intent_id: paymentIntent.id });
|
|
94
|
+
if (checkoutSession) {
|
|
95
|
+
checkoutSession.update({ payment_intent_id: paymentIntent.id });
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
logger.info('stripe payment intent mirrored', { locale: paymentIntent.id, remote: stripeIntent.id });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const waitForStripePaymentMirrored = (stripeInvoiceId: string) => {
|
|
103
|
+
return pWaitFor(
|
|
104
|
+
async () => {
|
|
105
|
+
const invoice = await Invoice.findOne({ where: { 'metadata.stripe_id': stripeInvoiceId } });
|
|
106
|
+
return !!invoice;
|
|
107
|
+
},
|
|
108
|
+
{ interval: 1000, timeout: 20 * 1000 }
|
|
109
|
+
);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export async function handlePaymentIntentEvent(event: TEventExpanded, client: Stripe) {
|
|
113
|
+
const localIntentId = event.data.object.metadata?.id;
|
|
114
|
+
if (!localIntentId) {
|
|
115
|
+
// We only handle payment_intents created from subscriptions
|
|
116
|
+
if (event.type === 'payment_intent.created') {
|
|
117
|
+
if (event.data.object.invoice) {
|
|
118
|
+
await handleStripePaymentCreated(event, client);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await waitForStripePaymentMirrored(event.data.object.id);
|
|
125
|
+
} catch (err) {
|
|
126
|
+
logger.error('wait for stripe payment intent mirror error', { localIntentId, error: err });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
logger.warn('local payment intent id not found in strip event', { localIntentId });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const paymentIntent = await PaymentIntent.findByPk(localIntentId);
|
|
134
|
+
if (!paymentIntent) {
|
|
135
|
+
logger.warn('local payment intent not found', { localIntentId });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
logger.info('received payment intent event', { id: event.id, type: event.type, localIntentId });
|
|
140
|
+
|
|
141
|
+
if (event.type === 'payment_intent.succeeded') {
|
|
142
|
+
await handleStripePaymentSucceed(paymentIntent, event);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (event.type === 'payment_intent.canceled') {
|
|
147
|
+
await paymentIntent.update({
|
|
148
|
+
status: 'canceled',
|
|
149
|
+
canceled_at: event.data.object.canceled_at,
|
|
150
|
+
cancellation_reason: event.data.object.cancellation_reason,
|
|
151
|
+
});
|
|
152
|
+
logger.info('payment intent canceled on stripe event', { locale: paymentIntent.id });
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (event.type === 'payment_intent.payment_failed') {
|
|
157
|
+
await paymentIntent.update({ status: 'requires_action' });
|
|
158
|
+
logger.info('payment intent failed on stripe event', { locale: paymentIntent.id });
|
|
159
|
+
|
|
160
|
+
if (paymentIntent.invoice_id) {
|
|
161
|
+
const invoice = await Invoice.findByPk(paymentIntent.invoice_id);
|
|
162
|
+
if (invoice) {
|
|
163
|
+
await invoice.update({
|
|
164
|
+
status: 'uncollectible',
|
|
165
|
+
attempt_count: invoice.attempt_count + 1,
|
|
166
|
+
attempted: true,
|
|
167
|
+
status_transitions: { ...invoice.status_transitions, marked_uncollectible_at: dayjs().unix() },
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type Stripe from 'stripe';
|
|
2
|
+
|
|
3
|
+
import logger from '../../../libs/logger';
|
|
4
|
+
import { CheckoutSession, Subscription, TEventExpanded } from '../../../store/models';
|
|
5
|
+
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
7
|
+
export async function handleSetupIntentEvent(event: TEventExpanded, _: Stripe) {
|
|
8
|
+
const stripeIntentId = event.data.object.id;
|
|
9
|
+
const subscription = await Subscription.findOne({
|
|
10
|
+
where: { 'payment_details.stripe.setup_intent_id': stripeIntentId },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
if (!subscription) {
|
|
14
|
+
logger.warn('local subscription not found for setup intent', { stripeIntentId });
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
logger.info('received setup intent event', { id: event.id, type: event.type, subscriptionId: subscription.id });
|
|
19
|
+
|
|
20
|
+
if (event.type === 'setup_intent.succeeded') {
|
|
21
|
+
if (subscription.status === 'incomplete') {
|
|
22
|
+
await subscription.update({ status: subscription.trail_end ? 'trialing' : 'active' });
|
|
23
|
+
logger.info('subscription become active on stripe intent succeeded', subscription.id);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const checkoutSession = await CheckoutSession.findOne({ where: { subscription_id: subscription.id } });
|
|
27
|
+
if (checkoutSession && checkoutSession.status === 'open') {
|
|
28
|
+
await checkoutSession.update({ status: 'complete', payment_status: 'no_payment_required' });
|
|
29
|
+
logger.info('checkout session become complete on stripe intent succeeded', checkoutSession.id);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (event.type === 'setup_intent.canceled') {
|
|
36
|
+
// FIXME:
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (event.type === 'setup_intent.payment_failed') {
|
|
40
|
+
// FIXME:
|
|
41
|
+
}
|
|
42
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import pick from 'lodash/pick';
|
|
2
|
+
import type Stripe from 'stripe';
|
|
3
|
+
|
|
4
|
+
import logger from '../../../libs/logger';
|
|
5
|
+
import { CheckoutSession, Subscription, TEventExpanded } from '../../../store/models';
|
|
6
|
+
|
|
7
|
+
export async function handleStripeSubscriptionSucceed(subscription: Subscription) {
|
|
8
|
+
await subscription.update({
|
|
9
|
+
status: 'active',
|
|
10
|
+
});
|
|
11
|
+
logger.info('subscription become active on stripe event', { id: subscription.id, status: subscription.status });
|
|
12
|
+
|
|
13
|
+
const checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: subscription.id } });
|
|
14
|
+
if (checkoutSession) {
|
|
15
|
+
await checkoutSession.update({
|
|
16
|
+
status: 'complete',
|
|
17
|
+
payment_status: 'paid',
|
|
18
|
+
payment_details: subscription.payment_details,
|
|
19
|
+
});
|
|
20
|
+
logger.info('checkout session become complete on stripe event', { id: checkoutSession.id });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// https://stripe.com/docs/billing/subscriptions/webhooks#events
|
|
25
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
26
|
+
export async function handleSubscriptionEvent(event: TEventExpanded, _: Stripe) {
|
|
27
|
+
const localSubscriptionId = event.data.object.metadata?.id;
|
|
28
|
+
if (!localSubscriptionId) {
|
|
29
|
+
logger.warn('local subscription id not found in strip event', { localSubscriptionId });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const subscription = await Subscription.findByPk(localSubscriptionId);
|
|
33
|
+
if (!subscription) {
|
|
34
|
+
logger.warn('local subscription not found', { localSubscriptionId });
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
logger.info('received subscription event', { id: event.id, type: event.type, localSubscriptionId });
|
|
39
|
+
|
|
40
|
+
if (event.type === 'customer.subscription.updated') {
|
|
41
|
+
if (event.data.previous_attributes?.status === 'incomplete' && event.data.object.status === 'active') {
|
|
42
|
+
await handleStripeSubscriptionSucceed(subscription);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
await subscription.update(
|
|
47
|
+
pick(event.data.object, ['cancel_at', 'cancel_at_period_end', 'canceled_at', 'pause_collection'])
|
|
48
|
+
);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (event.type === 'customer.subscription.deleted') {
|
|
53
|
+
await subscription.update({ status: 'canceled', ended_at: event.data.object.ended_at });
|
|
54
|
+
logger.info('subscription ended on stripe event', { id: subscription.id });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (event.type === 'customer.subscription.paused') {
|
|
58
|
+
await subscription.update({ status: 'paused', pause_collection: event.data.object.pause_collection });
|
|
59
|
+
logger.info('subscription paused on stripe event', { id: subscription.id });
|
|
60
|
+
}
|
|
61
|
+
}
|