payment-kit 1.13.17 → 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.
Files changed (109) hide show
  1. package/README.md +14 -0
  2. package/api/src/index.ts +17 -6
  3. package/api/src/integrations/stripe/handlers/index.ts +53 -0
  4. package/api/src/integrations/stripe/handlers/invoice.ts +252 -0
  5. package/api/src/integrations/stripe/handlers/payment-intent.ts +172 -0
  6. package/api/src/integrations/stripe/handlers/setup-intent.ts +42 -0
  7. package/api/src/integrations/stripe/handlers/subscription.ts +61 -0
  8. package/api/src/integrations/stripe/resource.ts +317 -0
  9. package/api/src/integrations/stripe/setup.ts +50 -0
  10. package/api/src/jobs/invoice.ts +11 -0
  11. package/api/src/jobs/payment.ts +15 -7
  12. package/api/src/jobs/subscription.ts +18 -2
  13. package/api/src/libs/session.ts +104 -8
  14. package/api/src/libs/util.ts +47 -1
  15. package/api/src/routes/checkout-sessions.ts +134 -27
  16. package/api/src/routes/connect/collect.ts +12 -4
  17. package/api/src/routes/connect/pay.ts +30 -20
  18. package/api/src/routes/connect/setup.ts +12 -4
  19. package/api/src/routes/connect/shared.ts +28 -4
  20. package/api/src/routes/connect/subscribe.ts +12 -5
  21. package/api/src/routes/customers.ts +5 -5
  22. package/api/src/routes/events.ts +9 -6
  23. package/api/src/routes/index.ts +2 -0
  24. package/api/src/routes/integrations/stripe.ts +64 -0
  25. package/api/src/routes/invoices.ts +19 -9
  26. package/api/src/routes/payment-intents.ts +19 -9
  27. package/api/src/routes/payment-links.ts +57 -15
  28. package/api/src/routes/payment-methods.ts +98 -1
  29. package/api/src/routes/prices.ts +71 -14
  30. package/api/src/routes/products.ts +79 -22
  31. package/api/src/routes/settings.ts +10 -11
  32. package/api/src/routes/subscription-items.ts +5 -5
  33. package/api/src/routes/subscriptions.ts +61 -10
  34. package/api/src/routes/usage-records.ts +52 -18
  35. package/api/src/routes/webhook-attempts.ts +5 -5
  36. package/api/src/routes/webhook-endpoints.ts +5 -5
  37. package/api/src/store/migrations/20230905-genesis.ts +2 -2
  38. package/api/src/store/migrations/20230911-seeding.ts +4 -3
  39. package/api/src/store/models/checkout-session.ts +15 -7
  40. package/api/src/store/models/index.ts +31 -7
  41. package/api/src/store/models/invoice.ts +1 -1
  42. package/api/src/store/models/payment-intent.ts +2 -5
  43. package/api/src/store/models/payment-link.ts +1 -1
  44. package/api/src/store/models/payment-method.ts +54 -33
  45. package/api/src/store/models/price.ts +52 -17
  46. package/api/src/store/models/product.ts +0 -3
  47. package/api/src/store/models/subscription.ts +3 -5
  48. package/api/src/store/models/types.ts +56 -2
  49. package/api/third.d.ts +2 -0
  50. package/blocklet.yml +1 -1
  51. package/package.json +36 -29
  52. package/public/currencies/dai.png +0 -0
  53. package/public/currencies/dollar.png +0 -0
  54. package/public/currencies/usdc.png +0 -0
  55. package/public/currencies/usdt.png +0 -0
  56. package/public/methods/arcblock.png +0 -0
  57. package/public/methods/binance.png +0 -0
  58. package/public/methods/coinbase.png +0 -0
  59. package/public/methods/ethereum.jpg +0 -0
  60. package/public/methods/stripe.png +0 -0
  61. package/src/components/checkout/form/address.tsx +86 -10
  62. package/src/components/checkout/form/index.tsx +169 -83
  63. package/src/components/checkout/form/phone.tsx +96 -0
  64. package/src/components/checkout/form/stripe.tsx +195 -0
  65. package/src/components/checkout/pay.tsx +115 -34
  66. package/src/components/checkout/product-item.tsx +4 -3
  67. package/src/components/checkout/summary.tsx +5 -4
  68. package/src/components/drawer-form.tsx +4 -4
  69. package/src/components/input.tsx +22 -4
  70. package/src/components/invoice/table.tsx +8 -3
  71. package/src/components/payment-link/before-pay.tsx +11 -6
  72. package/src/components/payment-link/chrome.tsx +13 -0
  73. package/src/components/payment-link/preview.tsx +31 -0
  74. package/src/components/payment-link/product-select.tsx +8 -3
  75. package/src/components/payment-method/arcblock.tsx +53 -0
  76. package/src/components/payment-method/bitcoin.tsx +53 -0
  77. package/src/components/payment-method/ethereum.tsx +53 -0
  78. package/src/components/payment-method/form.tsx +54 -0
  79. package/src/components/payment-method/stripe.tsx +45 -0
  80. package/src/components/portal/invoice/list.tsx +1 -1
  81. package/src/components/portal/subscription/list.tsx +1 -1
  82. package/src/components/price/currency-select.tsx +53 -0
  83. package/src/components/price/form.tsx +118 -24
  84. package/src/components/product/add-price.tsx +1 -1
  85. package/src/components/product/edit-price.tsx +6 -2
  86. package/src/components/subscription/items/index.tsx +7 -6
  87. package/src/components/subscription/items/usage-records.tsx +98 -0
  88. package/src/components/subscription/list.tsx +3 -2
  89. package/src/components/subscription/status.tsx +68 -0
  90. package/src/contexts/settings.tsx +2 -2
  91. package/src/env.d.ts +2 -0
  92. package/src/libs/util.ts +116 -21
  93. package/src/locales/en.tsx +71 -3
  94. package/src/pages/admin/billing/invoices/detail.tsx +5 -2
  95. package/src/pages/admin/billing/subscriptions/detail.tsx +6 -6
  96. package/src/pages/admin/customers/customers/detail.tsx +13 -1
  97. package/src/pages/admin/payments/intents/detail.tsx +8 -3
  98. package/src/pages/admin/payments/links/create.tsx +23 -3
  99. package/src/pages/admin/payments/links/detail.tsx +13 -26
  100. package/src/pages/admin/products/prices/detail.tsx +55 -11
  101. package/src/pages/admin/products/prices/list.tsx +7 -1
  102. package/src/pages/admin/products/products/create.tsx +1 -1
  103. package/src/pages/admin/products/products/detail.tsx +14 -7
  104. package/src/pages/admin/settings/index.tsx +16 -6
  105. package/src/pages/admin/settings/payment-methods/create.tsx +81 -0
  106. package/src/pages/admin/settings/{payment-methods.tsx → payment-methods/index.tsx} +9 -6
  107. package/src/pages/checkout/pay.tsx +3 -1
  108. package/src/pages/customer/index.tsx +12 -1
  109. 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(express.json({ limit: '1 mb' }));
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, _req, res, _next) => {
81
+ app.use(<ErrorRequestHandler>((err, req, res, _next) => {
77
82
  logger.error(err.stack);
78
- res.status(500).send('Something broke!');
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(`> ${name} v${version} ready on ${port}`);
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
+ }