payment-kit 1.13.18 → 1.13.20
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 +37 -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 +84 -10
- package/src/components/checkout/form/index.tsx +169 -83
- package/src/components/checkout/form/phone.tsx +102 -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/customer/edit.tsx +73 -0
- package/src/components/customer/form.tsx +104 -0
- 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/metadata/editor.tsx +2 -3
- package/src/components/payment-link/after-pay.tsx +1 -1
- 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-link/rename.tsx +2 -2
- 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 +72 -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 +43 -9
- 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 +36 -1
- package/public/.gitkeep +0 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
import env from '@blocklet/sdk/lib/env';
|
|
2
|
+
import merge from 'lodash/merge';
|
|
3
|
+
|
|
4
|
+
import logger from '../../libs/logger';
|
|
5
|
+
import { getPriceUintAmountByCurrency } from '../../libs/session';
|
|
6
|
+
import {
|
|
7
|
+
Customer,
|
|
8
|
+
PaymentCurrency,
|
|
9
|
+
PaymentIntent,
|
|
10
|
+
PaymentMethod,
|
|
11
|
+
Price,
|
|
12
|
+
Product,
|
|
13
|
+
Subscription,
|
|
14
|
+
SubscriptionItem,
|
|
15
|
+
TLineItemExpanded,
|
|
16
|
+
} from '../../store/models';
|
|
17
|
+
|
|
18
|
+
export async function ensureStripeProduct(internal: Product, method: PaymentMethod) {
|
|
19
|
+
const client = method.getStripe();
|
|
20
|
+
const result = await client.products.search({ query: `metadata['id']:'${internal.id}'` });
|
|
21
|
+
if (result.data.length > 0) {
|
|
22
|
+
return result.data[0] as any;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const attrs: any = {
|
|
26
|
+
name: internal.name,
|
|
27
|
+
description: internal.description,
|
|
28
|
+
metadata: {
|
|
29
|
+
appPid: env.appPid,
|
|
30
|
+
id: internal.id,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
if (internal.unit_label) {
|
|
34
|
+
attrs.unit_label = internal.unit_label;
|
|
35
|
+
}
|
|
36
|
+
if (internal.statement_descriptor) {
|
|
37
|
+
attrs.statement_descriptor = internal.statement_descriptor;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const product = await client.products.create(attrs);
|
|
41
|
+
await internal.update({ metadata: merge(internal.metadata || {}, { stripe_id: product.id }) });
|
|
42
|
+
logger.info('product created on stripe', { local: internal.id, remote: product.id });
|
|
43
|
+
|
|
44
|
+
return product;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function ensureStripePrice(internal: Price, method: PaymentMethod, currency: PaymentCurrency) {
|
|
48
|
+
const client = method.getStripe();
|
|
49
|
+
const result = await client.prices.search({ query: `metadata['id']:'${internal.id}'` });
|
|
50
|
+
if (result.data.length > 0) {
|
|
51
|
+
return result.data[0] as any;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// create stripe product
|
|
55
|
+
const local = await Product.findByPk(internal.product_id);
|
|
56
|
+
const product = await ensureStripeProduct(local as Product, method);
|
|
57
|
+
|
|
58
|
+
const attrs: any = {
|
|
59
|
+
product: product.id,
|
|
60
|
+
currency: currency.symbol.toLowerCase(),
|
|
61
|
+
nickname: internal.nickname as string,
|
|
62
|
+
lookup_key: internal.lookup_key as string,
|
|
63
|
+
billing_scheme: internal.billing_scheme as any,
|
|
64
|
+
metadata: {
|
|
65
|
+
appPid: env.appPid,
|
|
66
|
+
id: internal.id,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (internal.type === 'recurring') {
|
|
71
|
+
attrs.recurring = {
|
|
72
|
+
interval: internal.recurring?.interval,
|
|
73
|
+
interval_count: internal.recurring?.interval_count,
|
|
74
|
+
};
|
|
75
|
+
if (internal.recurring?.usage_type) {
|
|
76
|
+
attrs.recurring.usage_type = internal.recurring.usage_type;
|
|
77
|
+
}
|
|
78
|
+
if (internal.recurring?.usage_type === 'metered') {
|
|
79
|
+
attrs.recurring.aggregate_usage = internal.recurring.aggregate_usage;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (internal.tiers_mode) {
|
|
83
|
+
attrs.tiers_mode = internal.tiers_mode;
|
|
84
|
+
}
|
|
85
|
+
if (internal.tiers_mode === 'graduated' || internal.tiers_mode === 'volume') {
|
|
86
|
+
attrs.tiers = internal.tiers;
|
|
87
|
+
} else if (internal.transform_quantity) {
|
|
88
|
+
attrs.transform_quantity = internal.transform_quantity;
|
|
89
|
+
}
|
|
90
|
+
if (internal.custom_unit_amount) {
|
|
91
|
+
attrs.custom_unit_amount = internal.custom_unit_amount;
|
|
92
|
+
} else {
|
|
93
|
+
attrs.unit_amount = Number(getPriceUintAmountByCurrency(internal, currency));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// create stripe price
|
|
97
|
+
const price = await client.prices.create(attrs);
|
|
98
|
+
await internal.update({ metadata: merge(internal.metadata || {}, { stripe_id: price.id }) });
|
|
99
|
+
logger.info('price created on stripe', { local: internal.id, remote: price.id });
|
|
100
|
+
|
|
101
|
+
// update product default price
|
|
102
|
+
if (!product.default_price) {
|
|
103
|
+
await client.products.update(product.id, { default_price: price.id });
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return price;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export async function ensureStripeCustomer(internal: Customer, method: PaymentMethod) {
|
|
110
|
+
const client = method.getStripe();
|
|
111
|
+
const result = await client.customers.search({ query: `metadata['did']:'${internal.did}'` });
|
|
112
|
+
if (result.data.length > 0) {
|
|
113
|
+
return result.data[0] as any;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const customer = await client.customers.create({
|
|
117
|
+
name: internal.name,
|
|
118
|
+
email: internal.email,
|
|
119
|
+
phone: internal.phone,
|
|
120
|
+
invoice_prefix: internal.invoice_prefix,
|
|
121
|
+
metadata: {
|
|
122
|
+
appPid: env.appPid,
|
|
123
|
+
id: internal.id,
|
|
124
|
+
did: internal.did,
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await internal.update({ metadata: merge(internal.metadata || {}, { stripe_id: customer.id }) });
|
|
129
|
+
logger.info('customer created on stripe', { local: internal.id, remote: customer.id });
|
|
130
|
+
|
|
131
|
+
return customer;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export async function ensureStripePaymentCustomer(internal: any, method: PaymentMethod) {
|
|
135
|
+
const client = method.getStripe();
|
|
136
|
+
let customer = null;
|
|
137
|
+
if (internal.payment_details?.stripe?.customer_id) {
|
|
138
|
+
customer = await client.customers.retrieve(internal.payment_details.stripe.customer_id);
|
|
139
|
+
} else {
|
|
140
|
+
const local = await Customer.findByPk(internal.customer_id);
|
|
141
|
+
customer = await ensureStripeCustomer(local as Customer, method);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return customer;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function ensureStripePaymentIntent(
|
|
148
|
+
internal: PaymentIntent,
|
|
149
|
+
method: PaymentMethod,
|
|
150
|
+
currency: PaymentCurrency
|
|
151
|
+
) {
|
|
152
|
+
const client = method.getStripe();
|
|
153
|
+
|
|
154
|
+
let stripeIntent = null;
|
|
155
|
+
if (internal.payment_details?.stripe?.payment_intent_id) {
|
|
156
|
+
stripeIntent = await client.paymentIntents.retrieve(internal.payment_details.stripe.payment_intent_id);
|
|
157
|
+
// FIXME: update?
|
|
158
|
+
} else {
|
|
159
|
+
const customer = await ensureStripePaymentCustomer(internal, method);
|
|
160
|
+
stripeIntent = await client.paymentIntents.create({
|
|
161
|
+
amount: Number(internal.amount),
|
|
162
|
+
currency: currency.symbol.toLowerCase(),
|
|
163
|
+
customer: customer.id,
|
|
164
|
+
automatic_payment_methods: {
|
|
165
|
+
enabled: true,
|
|
166
|
+
allow_redirects: 'never',
|
|
167
|
+
},
|
|
168
|
+
statement_descriptor: internal.statement_descriptor,
|
|
169
|
+
metadata: {
|
|
170
|
+
appPid: env.appPid,
|
|
171
|
+
id: internal.id,
|
|
172
|
+
},
|
|
173
|
+
});
|
|
174
|
+
logger.info('stripe payment intent created', { local: internal.id, remote: stripeIntent.id });
|
|
175
|
+
|
|
176
|
+
await internal.update({
|
|
177
|
+
payment_details: {
|
|
178
|
+
stripe: {
|
|
179
|
+
customer_id: customer.id,
|
|
180
|
+
payment_intent_id: stripeIntent.id,
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
return stripeIntent;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export async function ensureStripeSubscription(
|
|
190
|
+
internal: Subscription,
|
|
191
|
+
method: PaymentMethod,
|
|
192
|
+
currency: PaymentCurrency,
|
|
193
|
+
items: TLineItemExpanded[],
|
|
194
|
+
trialInDays: number = 0
|
|
195
|
+
) {
|
|
196
|
+
const client = method.getStripe();
|
|
197
|
+
|
|
198
|
+
let stripeSubscription: any = null;
|
|
199
|
+
if (internal.payment_details?.stripe?.subscription_id) {
|
|
200
|
+
stripeSubscription = await client.subscriptions.retrieve(internal.payment_details.stripe.subscription_id, {
|
|
201
|
+
expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
|
|
202
|
+
});
|
|
203
|
+
// FIXME: update?
|
|
204
|
+
} else {
|
|
205
|
+
const customer = await ensureStripePaymentCustomer(internal, method);
|
|
206
|
+
const prices = await Promise.all(
|
|
207
|
+
items.map(async (x: any) => {
|
|
208
|
+
x.stripePrice = await ensureStripePrice(x.price as any, method, currency);
|
|
209
|
+
return x;
|
|
210
|
+
})
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
const recurringItems = prices
|
|
214
|
+
.filter((x) => x.price.type === 'recurring')
|
|
215
|
+
.map((x) => {
|
|
216
|
+
if (x.price.recurring?.usage_type === 'metered') {
|
|
217
|
+
return { price: x.stripePrice.id };
|
|
218
|
+
}
|
|
219
|
+
return { price: x.stripePrice.id, quantity: x.quantity };
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const onetimeItems = prices
|
|
223
|
+
.filter((x) => x.price.type !== 'recurring')
|
|
224
|
+
.map((x) => ({ price: x.stripePrice.id, quantity: x.quantity }));
|
|
225
|
+
|
|
226
|
+
stripeSubscription = await client.subscriptions.create({
|
|
227
|
+
currency: currency.symbol.toLowerCase(),
|
|
228
|
+
customer: customer.id,
|
|
229
|
+
items: recurringItems,
|
|
230
|
+
add_invoice_items: onetimeItems,
|
|
231
|
+
trial_period_days: trialInDays,
|
|
232
|
+
payment_behavior: 'default_incomplete',
|
|
233
|
+
payment_settings: { save_default_payment_method: 'on_subscription' },
|
|
234
|
+
metadata: {
|
|
235
|
+
appPid: env.appPid,
|
|
236
|
+
id: internal.id,
|
|
237
|
+
},
|
|
238
|
+
expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
|
|
239
|
+
});
|
|
240
|
+
logger.info('stripe subscription created', { local: internal.id, remote: stripeSubscription.id });
|
|
241
|
+
|
|
242
|
+
await Promise.all(
|
|
243
|
+
stripeSubscription.items.data.map(async (x: any) => {
|
|
244
|
+
const item = prices.find((y) => y.stripePrice.id === x.price.id);
|
|
245
|
+
let exist = await SubscriptionItem.findOne({
|
|
246
|
+
where: { price_id: item.price_id, subscription_id: internal.id },
|
|
247
|
+
});
|
|
248
|
+
if (exist) {
|
|
249
|
+
await exist.update({ metadata: { stripe_id: x.id, stripe_subscription_id: stripeSubscription.id } });
|
|
250
|
+
await client.subscriptionItems.update(x.id, { metadata: { appPid: env.appPid, id: exist.id } });
|
|
251
|
+
logger.info('stripe subscription items related', { local: exist.id, remote: x.id });
|
|
252
|
+
} else {
|
|
253
|
+
exist = await SubscriptionItem.create({
|
|
254
|
+
livemode: stripeSubscription.livemode,
|
|
255
|
+
price_id: item.price.id,
|
|
256
|
+
quantity: x.quantity,
|
|
257
|
+
subscription_id: internal.id,
|
|
258
|
+
billing_thresholds: x.billing_threshold,
|
|
259
|
+
metadata: {
|
|
260
|
+
stripe_id: x.id,
|
|
261
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
262
|
+
},
|
|
263
|
+
});
|
|
264
|
+
await client.subscriptionItems.update(x.id, { metadata: { appPid: env.appPid, id: exist.id } });
|
|
265
|
+
logger.info('stripe subscription items mirrored', { local: exist.id, remote: x.id });
|
|
266
|
+
}
|
|
267
|
+
})
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
await internal.update({
|
|
271
|
+
payment_details: {
|
|
272
|
+
stripe: {
|
|
273
|
+
customer_id: customer.id,
|
|
274
|
+
subscription_id: stripeSubscription.id,
|
|
275
|
+
setup_intent_id: stripeSubscription.pending_setup_intent?.id,
|
|
276
|
+
},
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return stripeSubscription;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
export async function forwardUsageRecordToStripe(
|
|
285
|
+
subscriptionItem: SubscriptionItem,
|
|
286
|
+
updates: { quantity: number; timestamp: number; action: string }
|
|
287
|
+
) {
|
|
288
|
+
if (!subscriptionItem.metadata?.stripe_id) {
|
|
289
|
+
logger.info('skip usage record forwarded to stripe because no relation', {
|
|
290
|
+
subscriptionItemId: subscriptionItem.id,
|
|
291
|
+
updates,
|
|
292
|
+
});
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const subscription = await Subscription.findByPk(subscriptionItem.subscription_id);
|
|
297
|
+
if (!subscription) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const method = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
302
|
+
if (!method) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const client = method.getStripe();
|
|
307
|
+
const result = await client.subscriptionItems.createUsageRecord(subscriptionItem.metadata.stripe_id, {
|
|
308
|
+
quantity: updates.quantity,
|
|
309
|
+
timestamp: updates.timestamp,
|
|
310
|
+
// @ts-ignore
|
|
311
|
+
action: subscription.billing_thresholds?.amount_gte ? 'increment' : updates.action,
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
logger.info('usage record forwarded to stripe', { subscriptionItemId: subscriptionItem.id, result });
|
|
315
|
+
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/* eslint-disable no-await-in-loop */
|
|
2
|
+
|
|
3
|
+
import env from '@blocklet/sdk/lib/env';
|
|
4
|
+
|
|
5
|
+
import logger from '../../libs/logger';
|
|
6
|
+
import { STRIPE_API_VERSION, STRIPE_ENDPOINT, STRIPE_EVENTS } from '../../libs/util';
|
|
7
|
+
import { PaymentMethod } from '../../store/models';
|
|
8
|
+
|
|
9
|
+
// register stripe webhooks on start/create if there are any stripe payment methods
|
|
10
|
+
export async function ensureWebhookRegistered() {
|
|
11
|
+
const stripeMethods = await PaymentMethod.findAll({ where: { type: 'stripe' } });
|
|
12
|
+
for (const method of stripeMethods) {
|
|
13
|
+
const settings = PaymentMethod.decryptSettings(method.settings);
|
|
14
|
+
if (!settings.stripe) {
|
|
15
|
+
// eslint-disable-next-line no-continue
|
|
16
|
+
continue;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const stripe = method.getStripe();
|
|
20
|
+
const { data } = await stripe.webhookEndpoints.list({ limit: 100 });
|
|
21
|
+
|
|
22
|
+
const exist = data.find((webhook) => webhook.metadata?.appPid === env.appPid);
|
|
23
|
+
if (exist) {
|
|
24
|
+
await stripe.webhookEndpoints.update(exist.id, {
|
|
25
|
+
url: STRIPE_ENDPOINT,
|
|
26
|
+
description: env.appName,
|
|
27
|
+
enabled_events: STRIPE_EVENTS,
|
|
28
|
+
disabled: false,
|
|
29
|
+
});
|
|
30
|
+
logger.info('stripe webhook updated');
|
|
31
|
+
} else {
|
|
32
|
+
const result = await stripe.webhookEndpoints.create({
|
|
33
|
+
url: STRIPE_ENDPOINT,
|
|
34
|
+
description: env.appName,
|
|
35
|
+
enabled_events: STRIPE_EVENTS,
|
|
36
|
+
api_version: STRIPE_API_VERSION,
|
|
37
|
+
metadata: {
|
|
38
|
+
appPid: env.appPid,
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
logger.info('stripe webhook created');
|
|
42
|
+
|
|
43
|
+
if (result.secret) {
|
|
44
|
+
settings.stripe.webhook_signing_secret = result.secret;
|
|
45
|
+
await method.update({ settings: PaymentMethod.encryptSettings(settings) });
|
|
46
|
+
logger.info('stripe webhook signing secret updated');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
package/api/src/jobs/invoice.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { Op } from 'sequelize';
|
|
|
3
3
|
import dayjs from '../libs/dayjs';
|
|
4
4
|
import logger from '../libs/logger';
|
|
5
5
|
import createQueue from '../libs/queue';
|
|
6
|
+
import { PaymentMethod } from '../store/models';
|
|
6
7
|
import { CheckoutSession } from '../store/models/checkout-session';
|
|
7
8
|
import { Invoice } from '../store/models/invoice';
|
|
8
9
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
@@ -33,6 +34,12 @@ export const handleInvoice = async (job: InvoiceJob) => {
|
|
|
33
34
|
return;
|
|
34
35
|
}
|
|
35
36
|
|
|
37
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(invoice.default_payment_method_id);
|
|
38
|
+
if (supportAutoCharge === false) {
|
|
39
|
+
logger.warn(`Invoice does not support auto charge: ${job.invoiceId}`);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
36
43
|
// no payment required
|
|
37
44
|
if (invoice.total === '0') {
|
|
38
45
|
logger.warn(`Invoice does not require payment: ${job.invoiceId}`);
|
|
@@ -136,6 +143,10 @@ export const startInvoiceQueue = async () => {
|
|
|
136
143
|
});
|
|
137
144
|
|
|
138
145
|
invoices.forEach(async (x) => {
|
|
146
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
147
|
+
if (supportAutoCharge === false) {
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
139
150
|
const exist = await invoiceQueue.get(x.id);
|
|
140
151
|
if (!exist) {
|
|
141
152
|
invoiceQueue.push({ id: x.id, job: { invoiceId: x.id, retryOnError: true } });
|
package/api/src/jobs/payment.ts
CHANGED
|
@@ -38,9 +38,9 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
38
38
|
return;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
logger.warn(`
|
|
41
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(paymentIntent.payment_method_id);
|
|
42
|
+
if (supportAutoCharge === false) {
|
|
43
|
+
logger.warn(`PaymentMethod does not support auto charge: ${paymentIntent.payment_method_id}`);
|
|
44
44
|
return;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -88,8 +88,10 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
88
88
|
status: 'succeeded',
|
|
89
89
|
amount_received: paymentIntent.amount,
|
|
90
90
|
payment_details: {
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
arcblock: {
|
|
92
|
+
tx_hash: txHash,
|
|
93
|
+
payer: paymentSettings?.payment_method_options.arcblock?.payer as string,
|
|
94
|
+
},
|
|
93
95
|
},
|
|
94
96
|
});
|
|
95
97
|
|
|
@@ -126,8 +128,10 @@ export const handlePayment = async (job: PaymentJob) => {
|
|
|
126
128
|
status: 'complete',
|
|
127
129
|
payment_status: 'paid',
|
|
128
130
|
payment_details: {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
+
arcblock: {
|
|
132
|
+
tx_hash: txHash,
|
|
133
|
+
payer: paymentSettings?.payment_method_options.arcblock?.payer as string,
|
|
134
|
+
},
|
|
131
135
|
},
|
|
132
136
|
});
|
|
133
137
|
logger.info(`CheckoutSession ${checkoutSession.id} updated on payment done ${invoice.id}`);
|
|
@@ -196,6 +200,10 @@ export const startPaymentQueue = async () => {
|
|
|
196
200
|
});
|
|
197
201
|
|
|
198
202
|
payments.forEach(async (x) => {
|
|
203
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.payment_method_id);
|
|
204
|
+
if (supportAutoCharge === false) {
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
199
207
|
const exist = await paymentQueue.get(x.id);
|
|
200
208
|
if (!exist) {
|
|
201
209
|
paymentQueue.push({ id: x.id, job: { paymentIntentId: x.id } });
|
|
@@ -5,7 +5,7 @@ import dayjs from '../libs/dayjs';
|
|
|
5
5
|
import logger from '../libs/logger';
|
|
6
6
|
import createQueue from '../libs/queue';
|
|
7
7
|
import { getStatementDescriptor, getSubscriptionCycleAmount, getSubscriptionCycleSetup } from '../libs/session';
|
|
8
|
-
import { UsageRecord } from '../store/models';
|
|
8
|
+
import { PaymentCurrency, PaymentMethod, UsageRecord } from '../store/models';
|
|
9
9
|
import { Customer } from '../store/models/customer';
|
|
10
10
|
import { Invoice } from '../store/models/invoice';
|
|
11
11
|
import { InvoiceItem } from '../store/models/invoice-item';
|
|
@@ -32,6 +32,11 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
32
32
|
logger.warn(`Subscription status not expected: ${job.subscriptionId}`);
|
|
33
33
|
return;
|
|
34
34
|
}
|
|
35
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(subscription.default_payment_method_id);
|
|
36
|
+
if (supportAutoCharge === false) {
|
|
37
|
+
logger.warn(`Subscription does not support auto charge: ${job.subscriptionId}`);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
35
40
|
|
|
36
41
|
const now = dayjs().unix();
|
|
37
42
|
|
|
@@ -86,6 +91,13 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
86
91
|
return;
|
|
87
92
|
}
|
|
88
93
|
|
|
94
|
+
// Do we still have the currency
|
|
95
|
+
const currency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
96
|
+
if (!currency) {
|
|
97
|
+
logger.warn(`Currency ${subscription.currency_id} not found for subscription: ${subscription.id}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
|
|
89
101
|
// get setup for next subscription period
|
|
90
102
|
const previousPeriodEnd =
|
|
91
103
|
subscription.status === 'trialing' ? subscription.trail_end : subscription.current_period_end;
|
|
@@ -154,7 +166,7 @@ export const handleSubscription = async (job: SubscriptionJob) => {
|
|
|
154
166
|
})
|
|
155
167
|
);
|
|
156
168
|
|
|
157
|
-
const amount = getSubscriptionCycleAmount(expandedItems);
|
|
169
|
+
const amount = getSubscriptionCycleAmount(expandedItems, currency);
|
|
158
170
|
|
|
159
171
|
// create invoice
|
|
160
172
|
const invoice = await Invoice.create({
|
|
@@ -285,6 +297,10 @@ export const startSubscriptionQueue = async () => {
|
|
|
285
297
|
});
|
|
286
298
|
|
|
287
299
|
subscriptions.forEach(async (x) => {
|
|
300
|
+
const supportAutoCharge = await PaymentMethod.supportAutoCharge(x.default_payment_method_id);
|
|
301
|
+
if (supportAutoCharge === false) {
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
288
304
|
const exist = await subscriptionQueue.get(x.id);
|
|
289
305
|
if (!exist) {
|
|
290
306
|
subscriptionQueue.push({
|
package/api/src/libs/session.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { env } from '@blocklet/sdk/lib/config';
|
|
2
2
|
import { BN } from '@ocap/util';
|
|
3
|
+
import cloneDeep from 'lodash/cloneDeep';
|
|
4
|
+
import isEqual from 'lodash/isEqual';
|
|
3
5
|
|
|
6
|
+
import type { TPaymentCurrency, TPaymentMethodExpanded } from '../store/models';
|
|
4
7
|
import type { Price, TPrice } from '../store/models/price';
|
|
5
8
|
import type { Product } from '../store/models/product';
|
|
6
|
-
import type { LineItem, PriceRecurring } from '../store/models/types';
|
|
9
|
+
import type { LineItem, PriceCurrency, PriceRecurring } from '../store/models/types';
|
|
7
10
|
import dayjs from './dayjs';
|
|
8
11
|
|
|
9
12
|
export type TLineItemExpanded = LineItem & { price: TPrice };
|
|
@@ -35,8 +38,26 @@ export function getCheckoutMode(items: TLineItemExpanded[] = []) {
|
|
|
35
38
|
return 'payment';
|
|
36
39
|
}
|
|
37
40
|
|
|
41
|
+
export function getPriceUintAmountByCurrency(price: TPrice, currency: TPaymentCurrency) {
|
|
42
|
+
const options = getPriceCurrencyOptions(price);
|
|
43
|
+
const option = options.find((x) => x.currency_id === currency.id);
|
|
44
|
+
if (option) {
|
|
45
|
+
return option.unit_amount;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return price.unit_amount;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function getPriceCurrencyOptions(price: TPrice): PriceCurrency[] {
|
|
52
|
+
if (Array.isArray(price.currency_options)) {
|
|
53
|
+
return price.currency_options;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return [{ currency_id: price.currency_id, unit_amount: price.unit_amount, tiers: null, custom_unit_amount: null }];
|
|
57
|
+
}
|
|
58
|
+
|
|
38
59
|
// FIXME: apply coupon for discounts
|
|
39
|
-
export function getCheckoutAmount(items: TLineItemExpanded[]
|
|
60
|
+
export function getCheckoutAmount(items: TLineItemExpanded[], currency: TPaymentCurrency, includeFreeTrial = false) {
|
|
40
61
|
const subtotal = items
|
|
41
62
|
.reduce((acc, x) => {
|
|
42
63
|
if (x.price.type === 'recurring') {
|
|
@@ -47,7 +68,7 @@ export function getCheckoutAmount(items: TLineItemExpanded[] = [], includeFreeTr
|
|
|
47
68
|
return acc;
|
|
48
69
|
}
|
|
49
70
|
}
|
|
50
|
-
return acc.add(new BN(x.price
|
|
71
|
+
return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
|
|
51
72
|
}, new BN(0))
|
|
52
73
|
.toString();
|
|
53
74
|
|
|
@@ -61,7 +82,7 @@ export function getCheckoutAmount(items: TLineItemExpanded[] = [], includeFreeTr
|
|
|
61
82
|
return acc;
|
|
62
83
|
}
|
|
63
84
|
}
|
|
64
|
-
return acc.add(new BN(x.price
|
|
85
|
+
return acc.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
|
|
65
86
|
}, new BN(0))
|
|
66
87
|
.toString();
|
|
67
88
|
|
|
@@ -89,7 +110,7 @@ export function getRecurringPeriod(recurring: PriceRecurring) {
|
|
|
89
110
|
}
|
|
90
111
|
}
|
|
91
112
|
|
|
92
|
-
export function getSubscriptionCreateSetup(items: TLineItemExpanded[], trialInDays = 0) {
|
|
113
|
+
export function getSubscriptionCreateSetup(items: TLineItemExpanded[], currency: TPaymentCurrency, trialInDays = 0) {
|
|
93
114
|
let setup = new BN(0);
|
|
94
115
|
let subscription = new BN(0);
|
|
95
116
|
|
|
@@ -97,7 +118,7 @@ export function getSubscriptionCreateSetup(items: TLineItemExpanded[], trialInDa
|
|
|
97
118
|
setup = setup.add(new BN(x.price.unit_amount).mul(new BN(x.quantity)));
|
|
98
119
|
if (x.price.type === 'recurring') {
|
|
99
120
|
if (trialInDays === 0) {
|
|
100
|
-
subscription = setup.add(new BN(x.price
|
|
121
|
+
subscription = setup.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
|
|
101
122
|
}
|
|
102
123
|
}
|
|
103
124
|
});
|
|
@@ -142,11 +163,11 @@ export function getSubscriptionCycleSetup(recurring: PriceRecurring, previousPer
|
|
|
142
163
|
};
|
|
143
164
|
}
|
|
144
165
|
|
|
145
|
-
export function getSubscriptionCycleAmount(items: TLineItemExpanded[]) {
|
|
166
|
+
export function getSubscriptionCycleAmount(items: TLineItemExpanded[], currency: TPaymentCurrency) {
|
|
146
167
|
let amount = new BN(0);
|
|
147
168
|
|
|
148
169
|
items.forEach((x) => {
|
|
149
|
-
amount = amount.add(new BN(x.price
|
|
170
|
+
amount = amount.add(new BN(getPriceUintAmountByCurrency(x.price, currency)).mul(new BN(x.quantity)));
|
|
150
171
|
});
|
|
151
172
|
|
|
152
173
|
return {
|
|
@@ -162,3 +183,78 @@ export function expandLineItems(items: any[], products: Product[], prices: Price
|
|
|
162
183
|
|
|
163
184
|
return items;
|
|
164
185
|
}
|
|
186
|
+
|
|
187
|
+
export function filterCurrencies(method: TPaymentMethodExpanded, hasSelected: (currency: TPaymentCurrency) => boolean) {
|
|
188
|
+
method.payment_currencies = method.payment_currencies.filter((x) => hasSelected(x));
|
|
189
|
+
return method;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function getSupportedPaymentMethods(
|
|
193
|
+
methods: TPaymentMethodExpanded[],
|
|
194
|
+
hasSelected: (currency: TPaymentCurrency) => boolean
|
|
195
|
+
) {
|
|
196
|
+
const filtered = cloneDeep(methods).map((x) => filterCurrencies(x, hasSelected));
|
|
197
|
+
return filtered.filter((x) => x.payment_currencies.length);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function getSupportedPaymentCurrencies(items: TLineItemExpanded[]) {
|
|
201
|
+
const currencies = items.reduce((acc: string[], x: any) => {
|
|
202
|
+
return acc.concat(getPriceCurrencyOptions(x.price).map((c: any) => c.currency_id));
|
|
203
|
+
}, []);
|
|
204
|
+
return Array.from(new Set(currencies));
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function isLineItemCurrencyAligned(list: TLineItemExpanded[], index: number) {
|
|
208
|
+
const prices = list.map((x) => x.price);
|
|
209
|
+
|
|
210
|
+
const current = getPriceCurrencyOptions(prices[index] as TPrice)
|
|
211
|
+
.map((x) => x.currency_id)
|
|
212
|
+
.sort();
|
|
213
|
+
|
|
214
|
+
for (let i = 0; i < index; i++) {
|
|
215
|
+
const previous = getPriceCurrencyOptions(prices[i] as TPrice)
|
|
216
|
+
.map((x) => x.currency_id)
|
|
217
|
+
.sort();
|
|
218
|
+
if (isEqual(current, previous) === false) {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return true;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function isLineItemRecurringAligned(list: TLineItemExpanded[], index: number) {
|
|
227
|
+
const prices = list.map((x) => x.price);
|
|
228
|
+
|
|
229
|
+
if (prices[index]?.type !== 'recurring') {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const recurring = prices.slice(0, index).find((x) => x?.type === 'recurring')?.recurring;
|
|
234
|
+
if (!recurring) {
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
return prices.slice(0, index + 1).every((x: any) => {
|
|
239
|
+
if (x.type !== 'recurring') {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// If the interval and interval_count are different, the recurring is not aligned
|
|
244
|
+
if (recurring?.interval !== x.recurring?.interval || recurring?.interval_count !== x.recurring?.interval_count) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return true;
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
export function isLineItemAligned(list: TLineItemExpanded[], index: number) {
|
|
253
|
+
const currency = isLineItemCurrencyAligned(list, index);
|
|
254
|
+
const recurring = isLineItemRecurringAligned(list, index);
|
|
255
|
+
return {
|
|
256
|
+
currency,
|
|
257
|
+
recurring,
|
|
258
|
+
aligned: currency && recurring,
|
|
259
|
+
};
|
|
260
|
+
}
|