payment-kit 1.13.15

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 (222) hide show
  1. package/.eslintrc.js +15 -0
  2. package/README.md +3 -0
  3. package/api/dev.ts +6 -0
  4. package/api/hooks/pre-start.js +12 -0
  5. package/api/src/hooks/pre-start.ts +21 -0
  6. package/api/src/index.ts +92 -0
  7. package/api/src/jobs/event.ts +72 -0
  8. package/api/src/jobs/invoice.ts +148 -0
  9. package/api/src/jobs/payment.ts +208 -0
  10. package/api/src/jobs/subscription.ts +301 -0
  11. package/api/src/jobs/webhook.ts +113 -0
  12. package/api/src/libs/audit.ts +73 -0
  13. package/api/src/libs/auth.ts +40 -0
  14. package/api/src/libs/chain/arcblock.ts +13 -0
  15. package/api/src/libs/dayjs.ts +17 -0
  16. package/api/src/libs/env.ts +5 -0
  17. package/api/src/libs/hooks.ts +42 -0
  18. package/api/src/libs/logger.ts +27 -0
  19. package/api/src/libs/middleware.ts +12 -0
  20. package/api/src/libs/payment.ts +53 -0
  21. package/api/src/libs/queue/index.ts +263 -0
  22. package/api/src/libs/queue/store.ts +47 -0
  23. package/api/src/libs/security.ts +95 -0
  24. package/api/src/libs/session.ts +164 -0
  25. package/api/src/libs/util.ts +93 -0
  26. package/api/src/locales/en.ts +3 -0
  27. package/api/src/locales/index.ts +37 -0
  28. package/api/src/locales/zh.ts +3 -0
  29. package/api/src/routes/checkout-sessions.ts +536 -0
  30. package/api/src/routes/connect/collect.ts +109 -0
  31. package/api/src/routes/connect/pay.ts +116 -0
  32. package/api/src/routes/connect/setup.ts +121 -0
  33. package/api/src/routes/connect/shared.ts +410 -0
  34. package/api/src/routes/connect/subscribe.ts +128 -0
  35. package/api/src/routes/customers.ts +70 -0
  36. package/api/src/routes/events.ts +76 -0
  37. package/api/src/routes/index.ts +59 -0
  38. package/api/src/routes/invoices.ts +126 -0
  39. package/api/src/routes/payment-currencies.ts +38 -0
  40. package/api/src/routes/payment-intents.ts +122 -0
  41. package/api/src/routes/payment-links.ts +221 -0
  42. package/api/src/routes/payment-methods.ts +39 -0
  43. package/api/src/routes/prices.ts +134 -0
  44. package/api/src/routes/products.ts +191 -0
  45. package/api/src/routes/settings.ts +33 -0
  46. package/api/src/routes/subscription-items.ts +148 -0
  47. package/api/src/routes/subscriptions.ts +254 -0
  48. package/api/src/routes/usage-records.ts +120 -0
  49. package/api/src/routes/webhook-attempts.ts +57 -0
  50. package/api/src/routes/webhook-endpoints.ts +105 -0
  51. package/api/src/store/migrate.ts +16 -0
  52. package/api/src/store/migrations/20230905-genesis.ts +52 -0
  53. package/api/src/store/migrations/20230911-seeding.ts +145 -0
  54. package/api/src/store/models/checkout-session.ts +395 -0
  55. package/api/src/store/models/coupon.ts +137 -0
  56. package/api/src/store/models/customer.ts +199 -0
  57. package/api/src/store/models/discount.ts +116 -0
  58. package/api/src/store/models/event.ts +111 -0
  59. package/api/src/store/models/index.ts +165 -0
  60. package/api/src/store/models/invoice-item.ts +185 -0
  61. package/api/src/store/models/invoice.ts +492 -0
  62. package/api/src/store/models/job.ts +75 -0
  63. package/api/src/store/models/payment-currency.ts +139 -0
  64. package/api/src/store/models/payment-intent.ts +282 -0
  65. package/api/src/store/models/payment-link.ts +219 -0
  66. package/api/src/store/models/payment-method.ts +169 -0
  67. package/api/src/store/models/price.ts +266 -0
  68. package/api/src/store/models/product.ts +162 -0
  69. package/api/src/store/models/promotion-code.ts +112 -0
  70. package/api/src/store/models/setup-intent.ts +206 -0
  71. package/api/src/store/models/subscription-item.ts +103 -0
  72. package/api/src/store/models/subscription-schedule.ts +157 -0
  73. package/api/src/store/models/subscription.ts +307 -0
  74. package/api/src/store/models/types.ts +406 -0
  75. package/api/src/store/models/usage-record.ts +132 -0
  76. package/api/src/store/models/webhook-attempt.ts +96 -0
  77. package/api/src/store/models/webhook-endpoint.ts +96 -0
  78. package/api/src/store/sequelize.ts +15 -0
  79. package/api/third.d.ts +28 -0
  80. package/blocklet.md +3 -0
  81. package/blocklet.yml +89 -0
  82. package/index.html +14 -0
  83. package/logo.png +0 -0
  84. package/package.json +133 -0
  85. package/public/.gitkeep +0 -0
  86. package/screenshots/.gitkeep +0 -0
  87. package/screenshots/1-subscription.png +0 -0
  88. package/screenshots/2-customer-1.png +0 -0
  89. package/screenshots/3-customer-2.png +0 -0
  90. package/screenshots/4-admin-3.png +0 -0
  91. package/screenshots/5-admin-4.png +0 -0
  92. package/scripts/build-clean.js +6 -0
  93. package/scripts/bump-version.mjs +35 -0
  94. package/src/app.tsx +68 -0
  95. package/src/components/actions.tsx +85 -0
  96. package/src/components/blockchain/tx.tsx +29 -0
  97. package/src/components/checkout/amount.tsx +24 -0
  98. package/src/components/checkout/error.tsx +30 -0
  99. package/src/components/checkout/footer.tsx +12 -0
  100. package/src/components/checkout/form/address.tsx +38 -0
  101. package/src/components/checkout/form/index.tsx +295 -0
  102. package/src/components/checkout/header.tsx +23 -0
  103. package/src/components/checkout/pay.tsx +222 -0
  104. package/src/components/checkout/product-card.tsx +56 -0
  105. package/src/components/checkout/product-item.tsx +37 -0
  106. package/src/components/checkout/skeleton/overview.tsx +21 -0
  107. package/src/components/checkout/skeleton/payment.tsx +35 -0
  108. package/src/components/checkout/success.tsx +183 -0
  109. package/src/components/checkout/summary.tsx +34 -0
  110. package/src/components/collapse.tsx +50 -0
  111. package/src/components/confirm.tsx +55 -0
  112. package/src/components/copyable.tsx +38 -0
  113. package/src/components/currency.tsx +15 -0
  114. package/src/components/customer/actions.tsx +73 -0
  115. package/src/components/data.tsx +20 -0
  116. package/src/components/drawer-form.tsx +77 -0
  117. package/src/components/error-fallback.tsx +7 -0
  118. package/src/components/error.tsx +39 -0
  119. package/src/components/event/list.tsx +217 -0
  120. package/src/components/info-card.tsx +40 -0
  121. package/src/components/info-metric.tsx +35 -0
  122. package/src/components/info-row.tsx +28 -0
  123. package/src/components/input.tsx +40 -0
  124. package/src/components/invoice/action.tsx +94 -0
  125. package/src/components/invoice/list.tsx +225 -0
  126. package/src/components/invoice/table.tsx +110 -0
  127. package/src/components/layout.tsx +70 -0
  128. package/src/components/livemode.tsx +23 -0
  129. package/src/components/metadata/editor.tsx +57 -0
  130. package/src/components/metadata/form.tsx +45 -0
  131. package/src/components/payment-intent/actions.tsx +81 -0
  132. package/src/components/payment-intent/list.tsx +204 -0
  133. package/src/components/payment-link/actions.tsx +114 -0
  134. package/src/components/payment-link/after-pay.tsx +87 -0
  135. package/src/components/payment-link/before-pay.tsx +175 -0
  136. package/src/components/payment-link/item.tsx +135 -0
  137. package/src/components/payment-link/product-select.tsx +66 -0
  138. package/src/components/payment-link/rename.tsx +64 -0
  139. package/src/components/portal/invoice/list.tsx +110 -0
  140. package/src/components/portal/subscription/cancel.tsx +83 -0
  141. package/src/components/portal/subscription/list.tsx +232 -0
  142. package/src/components/price/actions.tsx +21 -0
  143. package/src/components/price/form.tsx +292 -0
  144. package/src/components/product/actions.tsx +125 -0
  145. package/src/components/product/add-price.tsx +59 -0
  146. package/src/components/product/create.tsx +97 -0
  147. package/src/components/product/edit-price.tsx +75 -0
  148. package/src/components/product/edit.tsx +67 -0
  149. package/src/components/product/features.tsx +32 -0
  150. package/src/components/product/form.tsx +76 -0
  151. package/src/components/relative-time.tsx +41 -0
  152. package/src/components/section/header.tsx +29 -0
  153. package/src/components/status.tsx +12 -0
  154. package/src/components/subscription/actions/cancel.tsx +66 -0
  155. package/src/components/subscription/actions/index.tsx +172 -0
  156. package/src/components/subscription/actions/pause.tsx +83 -0
  157. package/src/components/subscription/items/actions.tsx +31 -0
  158. package/src/components/subscription/items/index.tsx +107 -0
  159. package/src/components/subscription/list.tsx +200 -0
  160. package/src/components/switch.tsx +48 -0
  161. package/src/components/table.tsx +66 -0
  162. package/src/components/uploader.tsx +81 -0
  163. package/src/components/webhook/attempts.tsx +149 -0
  164. package/src/contexts/products.tsx +42 -0
  165. package/src/contexts/session.ts +10 -0
  166. package/src/contexts/settings.tsx +54 -0
  167. package/src/env.d.ts +17 -0
  168. package/src/global.css +97 -0
  169. package/src/hooks/mobile.ts +15 -0
  170. package/src/index.tsx +6 -0
  171. package/src/libs/api.ts +19 -0
  172. package/src/libs/dayjs.ts +17 -0
  173. package/src/libs/util.ts +474 -0
  174. package/src/locales/en.tsx +395 -0
  175. package/src/locales/index.tsx +8 -0
  176. package/src/locales/zh.tsx +389 -0
  177. package/src/pages/admin/billing/index.tsx +56 -0
  178. package/src/pages/admin/billing/invoices/detail.tsx +215 -0
  179. package/src/pages/admin/billing/invoices/index.tsx +5 -0
  180. package/src/pages/admin/billing/subscriptions/detail.tsx +237 -0
  181. package/src/pages/admin/billing/subscriptions/index.tsx +5 -0
  182. package/src/pages/admin/customers/customers/detail.tsx +209 -0
  183. package/src/pages/admin/customers/customers/index.tsx +109 -0
  184. package/src/pages/admin/customers/index.tsx +47 -0
  185. package/src/pages/admin/developers/events/detail.tsx +77 -0
  186. package/src/pages/admin/developers/events/index.tsx +5 -0
  187. package/src/pages/admin/developers/index.tsx +60 -0
  188. package/src/pages/admin/developers/logs.tsx +3 -0
  189. package/src/pages/admin/developers/overview.tsx +3 -0
  190. package/src/pages/admin/developers/webhooks/detail.tsx +109 -0
  191. package/src/pages/admin/developers/webhooks/index.tsx +102 -0
  192. package/src/pages/admin/index.tsx +120 -0
  193. package/src/pages/admin/overview.tsx +3 -0
  194. package/src/pages/admin/payments/index.tsx +65 -0
  195. package/src/pages/admin/payments/intents/detail.tsx +205 -0
  196. package/src/pages/admin/payments/intents/index.tsx +5 -0
  197. package/src/pages/admin/payments/links/create.tsx +141 -0
  198. package/src/pages/admin/payments/links/detail.tsx +318 -0
  199. package/src/pages/admin/payments/links/index.tsx +167 -0
  200. package/src/pages/admin/products/coupons/index.tsx +3 -0
  201. package/src/pages/admin/products/index.tsx +81 -0
  202. package/src/pages/admin/products/prices/actions.tsx +151 -0
  203. package/src/pages/admin/products/prices/detail.tsx +203 -0
  204. package/src/pages/admin/products/prices/list.tsx +95 -0
  205. package/src/pages/admin/products/pricing-tables.tsx +3 -0
  206. package/src/pages/admin/products/products/create.tsx +105 -0
  207. package/src/pages/admin/products/products/detail.tsx +246 -0
  208. package/src/pages/admin/products/products/index.tsx +154 -0
  209. package/src/pages/admin/settings/branding.tsx +3 -0
  210. package/src/pages/admin/settings/business.tsx +3 -0
  211. package/src/pages/admin/settings/index.tsx +47 -0
  212. package/src/pages/admin/settings/payment-methods.tsx +80 -0
  213. package/src/pages/checkout/index.tsx +38 -0
  214. package/src/pages/checkout/pay.tsx +89 -0
  215. package/src/pages/customer/index.tsx +93 -0
  216. package/src/pages/customer/invoice.tsx +147 -0
  217. package/src/pages/home.tsx +9 -0
  218. package/tsconfig.api.json +9 -0
  219. package/tsconfig.eslint.json +7 -0
  220. package/tsconfig.json +99 -0
  221. package/tsconfig.types.json +11 -0
  222. package/vite.config.ts +19 -0
@@ -0,0 +1,410 @@
1
+ import { BN } from '@ocap/util';
2
+
3
+ import { blocklet } from '../../libs/auth';
4
+ import dayjs from '../../libs/dayjs';
5
+ import logger from '../../libs/logger';
6
+ import { TLineItemExpanded, getStatementDescriptor } from '../../libs/session';
7
+ import { CheckoutSession } from '../../store/models/checkout-session';
8
+ import { Customer } from '../../store/models/customer';
9
+ import { Invoice } from '../../store/models/invoice';
10
+ import { InvoiceItem } from '../../store/models/invoice-item';
11
+ import { PaymentCurrency } from '../../store/models/payment-currency';
12
+ import { PaymentIntent } from '../../store/models/payment-intent';
13
+ import { PaymentMethod } from '../../store/models/payment-method';
14
+ import { Price } from '../../store/models/price';
15
+ import { SetupIntent } from '../../store/models/setup-intent';
16
+ import { Subscription } from '../../store/models/subscription';
17
+ import { SubscriptionItem } from '../../store/models/subscription-item';
18
+
19
+ type Result = {
20
+ checkoutSession: CheckoutSession;
21
+ customer: Customer;
22
+ paymentIntent?: PaymentIntent;
23
+ subscription?: Subscription;
24
+ paymentCurrency: PaymentCurrency;
25
+ paymentMethod: PaymentMethod;
26
+ };
27
+
28
+ export async function ensureCheckoutSession(checkoutSessionId: string) {
29
+ const checkoutSession = await CheckoutSession.findByPk(checkoutSessionId);
30
+ if (!checkoutSession) {
31
+ throw new Error('Checkout session not found');
32
+ }
33
+ if (checkoutSession.status === 'complete') {
34
+ throw new Error('Checkout session completed');
35
+ }
36
+ if (checkoutSession.status === 'expired') {
37
+ throw new Error('Checkout session expired');
38
+ }
39
+
40
+ return checkoutSession;
41
+ }
42
+
43
+ export async function ensurePaymentIntent(checkoutSessionId: string, userDid: string): Promise<Result> {
44
+ const checkoutSession = await ensureCheckoutSession(checkoutSessionId);
45
+
46
+ let customerId;
47
+ let paymentCurrencyId;
48
+ let paymentMethodId;
49
+
50
+ let paymentIntent;
51
+ if (checkoutSession.payment_intent_id) {
52
+ paymentIntent = await PaymentIntent.findByPk(checkoutSession.payment_intent_id);
53
+ if (!paymentIntent) {
54
+ throw new Error('Payment intent not found');
55
+ }
56
+ if (paymentIntent.status === 'succeeded') {
57
+ throw new Error('Payment intent completed');
58
+ }
59
+ if (paymentIntent.status === 'canceled') {
60
+ throw new Error('Payment intent canceled');
61
+ }
62
+ if (paymentIntent.status === 'processing') {
63
+ throw new Error('Payment intent processing');
64
+ }
65
+
66
+ customerId = paymentIntent.customer_id;
67
+ paymentCurrencyId = paymentIntent.currency_id;
68
+ paymentMethodId = paymentIntent.payment_method_id;
69
+ }
70
+
71
+ let subscription;
72
+ if (checkoutSession.subscription_id) {
73
+ subscription = await Subscription.findByPk(checkoutSession.subscription_id);
74
+ if (!subscription) {
75
+ throw new Error('Subscription not found');
76
+ }
77
+ if (subscription.status !== 'incomplete') {
78
+ throw new Error('Subscription is not in incomplete status');
79
+ }
80
+
81
+ customerId = subscription.customer_id;
82
+ paymentCurrencyId = subscription.currency_id;
83
+ paymentMethodId = subscription.default_payment_method_id;
84
+ }
85
+
86
+ let customer;
87
+ if (customerId) {
88
+ customer = await Customer.findByPk(customerId);
89
+ if (!customer) {
90
+ throw new Error('Customer not found');
91
+ }
92
+ const { user } = await blocklet.getUser(userDid, { enableConnectedAccount: true });
93
+ if (customer.did !== user.did) {
94
+ throw new Error('This is not your payment intent');
95
+ }
96
+ }
97
+
98
+ const [paymentMethod, paymentCurrency] = await Promise.all([
99
+ PaymentMethod.findByPk(paymentMethodId),
100
+ PaymentCurrency.findByPk(paymentCurrencyId),
101
+ ]);
102
+ if (!paymentMethod) {
103
+ throw new Error('Payment method not found');
104
+ }
105
+ if (!paymentCurrency) {
106
+ throw new Error('Payment currency not found');
107
+ }
108
+
109
+ if (['arcblock', 'ethereum'].includes(paymentMethod.type) === false) {
110
+ throw new Error(`Payment method ${paymentMethod.type} should not be here`);
111
+ }
112
+
113
+ checkoutSession.line_items = await Price.expand(checkoutSession.line_items, false);
114
+
115
+ return {
116
+ checkoutSession,
117
+ paymentIntent,
118
+ customer: customer as Customer,
119
+ subscription,
120
+ paymentMethod,
121
+ paymentCurrency,
122
+ };
123
+ }
124
+
125
+ export async function ensureSetupIntent(checkoutSessionId: string, userDid: string) {
126
+ const checkoutSession = await ensureCheckoutSession(checkoutSessionId);
127
+
128
+ let customerId;
129
+ let paymentCurrencyId;
130
+ let paymentMethodId;
131
+
132
+ let setupIntent;
133
+ if (checkoutSession.setup_intent_id) {
134
+ setupIntent = await SetupIntent.findByPk(checkoutSession.setup_intent_id);
135
+ if (!setupIntent) {
136
+ throw new Error('Payment intent not found');
137
+ }
138
+ if (setupIntent.status === 'succeeded') {
139
+ throw new Error('Payment intent completed');
140
+ }
141
+ if (setupIntent.status === 'canceled') {
142
+ throw new Error('Payment intent canceled');
143
+ }
144
+ if (setupIntent.status === 'processing') {
145
+ throw new Error('Payment intent processing');
146
+ }
147
+
148
+ customerId = setupIntent.customer_id;
149
+ paymentCurrencyId = setupIntent.currency_id;
150
+ paymentMethodId = setupIntent.payment_method_id;
151
+ }
152
+
153
+ let subscription;
154
+ if (checkoutSession.subscription_id) {
155
+ subscription = await Subscription.findByPk(checkoutSession.subscription_id);
156
+ if (!subscription) {
157
+ throw new Error('Subscription not found');
158
+ }
159
+ if (subscription.status !== 'incomplete') {
160
+ throw new Error('Subscription is not in incomplete status');
161
+ }
162
+
163
+ customerId = subscription.customer_id;
164
+ paymentCurrencyId = subscription.currency_id;
165
+ paymentMethodId = subscription.default_payment_method_id;
166
+ }
167
+
168
+ let customer;
169
+ if (customerId) {
170
+ customer = await Customer.findByPk(customerId);
171
+ if (!customer) {
172
+ throw new Error('Customer not found');
173
+ }
174
+ const { user } = await blocklet.getUser(userDid, { enableConnectedAccount: true });
175
+ if (customer.did !== user.did) {
176
+ throw new Error('This is not your payment intent');
177
+ }
178
+ }
179
+
180
+ const [paymentMethod, paymentCurrency] = await Promise.all([
181
+ PaymentMethod.findByPk(paymentMethodId),
182
+ PaymentCurrency.findByPk(paymentCurrencyId),
183
+ ]);
184
+ if (!paymentMethod) {
185
+ throw new Error('Payment method not found');
186
+ }
187
+ if (!paymentCurrency) {
188
+ throw new Error('Payment currency not found');
189
+ }
190
+
191
+ if (['arcblock', 'ethereum'].includes(paymentMethod.type) === false) {
192
+ throw new Error(`Payment method ${paymentMethod.type} should not be here`);
193
+ }
194
+
195
+ checkoutSession.line_items = await Price.expand(checkoutSession.line_items, false);
196
+
197
+ return {
198
+ checkoutSession,
199
+ setupIntent: setupIntent as SetupIntent,
200
+ customer: customer as Customer,
201
+ subscription,
202
+ paymentMethod,
203
+ paymentCurrency,
204
+ };
205
+ }
206
+
207
+ type Args = {
208
+ checkoutSession: CheckoutSession;
209
+ customer: Customer;
210
+ paymentIntent?: PaymentIntent;
211
+ subscription?: Subscription;
212
+ };
213
+
214
+ export async function ensureInvoiceForCheckout({
215
+ checkoutSession,
216
+ customer,
217
+ paymentIntent,
218
+ subscription,
219
+ }: Args): Promise<{ invoice: Invoice | null; items: InvoiceItem[] }> {
220
+ // invoices are optional when checkout session is in payment mode
221
+ if (checkoutSession.mode === 'payment' && !checkoutSession.invoice_creation?.enabled) {
222
+ logger.warn('Invoice creation disabled for payment mode');
223
+ return { invoice: null, items: [] };
224
+ }
225
+
226
+ // Do not create invoice if it's already created
227
+ if (checkoutSession.invoice_id) {
228
+ logger.warn(`Invoice already created for checkout session ${checkoutSession.id}: ${checkoutSession.invoice_id}`);
229
+ return {
230
+ invoice: await Invoice.findByPk(checkoutSession.invoice_id),
231
+ items: await InvoiceItem.findAll({ where: { invoice_id: checkoutSession.invoice_id } }),
232
+ };
233
+ }
234
+
235
+ const invoice = await Invoice.create({
236
+ livemode: checkoutSession.livemode,
237
+ number: await customer.getInvoiceNumber(),
238
+ description: paymentIntent?.description || 'Subscription creation',
239
+ statement_descriptor: paymentIntent?.statement_descriptor || getStatementDescriptor(checkoutSession.line_items),
240
+ period_start: subscription?.current_period_start ?? 0,
241
+ period_end: subscription?.current_period_end ?? 0,
242
+
243
+ auto_advance: !paymentIntent,
244
+ paid: false,
245
+ paid_out_of_band: false,
246
+
247
+ status: 'open',
248
+ collection_method: 'charge_automatically',
249
+ billing_reason: subscription ? 'subscription_create' : 'manual',
250
+
251
+ currency_id: checkoutSession.currency_id,
252
+ customer_id: customer.id,
253
+ payment_intent_id: paymentIntent?.id,
254
+ subscription_id: subscription?.id,
255
+ checkout_session_id: checkoutSession.id,
256
+
257
+ subtotal: checkoutSession.amount_subtotal,
258
+ subtotal_excluding_tax: checkoutSession.amount_subtotal,
259
+ tax: '0',
260
+ total: checkoutSession.amount_total,
261
+ amount_due: checkoutSession.amount_total,
262
+ amount_paid: '0',
263
+ amount_remaining: checkoutSession.amount_total,
264
+ amount_shipping: '0',
265
+
266
+ starting_balance: '0',
267
+ ending_balance: '0',
268
+
269
+ attempt_count: 0,
270
+ attempted: false,
271
+ // next_payment_attempt: undefined,
272
+
273
+ custom_fields: [],
274
+ customer_address: customer.address,
275
+ customer_email: customer.email,
276
+ customer_name: customer.name,
277
+ customer_phone: customer.phone,
278
+
279
+ discounts: [],
280
+ total_discount_amounts: [],
281
+
282
+ due_date: undefined, // The date on which payment for this invoice is due
283
+ effective_at: dayjs().unix(), // The date when this invoice is in effect
284
+ status_transitions: {
285
+ finalized_at: dayjs().unix(),
286
+ },
287
+
288
+ payment_settings: subscription?.payment_settings,
289
+ default_payment_method_id: (subscription?.default_payment_method_id || paymentIntent?.payment_method_id) as string,
290
+
291
+ account_country: '',
292
+ account_name: '',
293
+ metadata: {},
294
+ });
295
+ logger.info(`Invoice created for checkoutSession ${checkoutSession.id}: ${invoice.id}`);
296
+
297
+ // persist invoice id
298
+ await checkoutSession.update({ invoice_id: invoice.id });
299
+ if (paymentIntent) {
300
+ await paymentIntent.update({ invoice_id: invoice.id });
301
+ }
302
+ if (subscription) {
303
+ await subscription.update({ latest_invoice_id: invoice.id });
304
+ }
305
+
306
+ // create invoice items: for those require payment this time
307
+ const subscriptionItems = subscription
308
+ ? await SubscriptionItem.findAll({ where: { subscription_id: subscription?.id } })
309
+ : [];
310
+ const lineItems = await Price.expand(checkoutSession.line_items, true);
311
+
312
+ const trailing = !!checkoutSession.subscription_data?.trial_period_days;
313
+ const getLineSetup = (x: TLineItemExpanded) => {
314
+ if (x.price.type === 'recurring' && trailing) {
315
+ return {
316
+ amount: '0',
317
+ // @ts-ignore
318
+ description: trailing ? `${x.price.product.name} (trailing)` : x.price.product.name,
319
+ period: {
320
+ start: subscription?.current_period_start as number,
321
+ end: subscription?.current_period_end as number,
322
+ },
323
+ };
324
+ }
325
+
326
+ return {
327
+ amount: new BN(x.price.unit_amount).mul(new BN(x.quantity)).toString(),
328
+ // @ts-ignore
329
+ description: x.price.product.name,
330
+ period: undefined,
331
+ };
332
+ };
333
+
334
+ const items = await Promise.all(
335
+ lineItems.map((x: TLineItemExpanded) => {
336
+ const setup = getLineSetup(x);
337
+ let { quantity } = x;
338
+ if (x.price.type === 'recurring') {
339
+ if (x.price.recurring?.usage_type === 'metered') {
340
+ quantity = 0;
341
+ }
342
+ if (trailing) {
343
+ quantity = 0;
344
+ }
345
+ }
346
+
347
+ return InvoiceItem.create({
348
+ livemode: checkoutSession.livemode,
349
+ amount: setup.amount,
350
+ quantity,
351
+ description: setup.description,
352
+ period: setup.period,
353
+ currency_id: checkoutSession.currency_id,
354
+ customer_id: customer.id,
355
+ price_id: x.price_id,
356
+ invoice_id: invoice.id,
357
+ subscription_id: subscription?.id,
358
+ subscription_item_id: subscriptionItems.find((si) => si.price_id === x.price_id)?.id,
359
+ discountable: false,
360
+ discounts: [],
361
+ discount_amounts: [],
362
+ proration: false,
363
+ proration_details: {},
364
+ metadata: {},
365
+ });
366
+ })
367
+ );
368
+
369
+ return { invoice, items };
370
+ }
371
+
372
+ export async function ensureInvoiceForCollect(invoiceId: string) {
373
+ const invoice = await Invoice.findByPk(invoiceId);
374
+ if (!invoice) {
375
+ throw new Error('Invoice not found');
376
+ }
377
+ if (invoice.status === 'paid') {
378
+ throw new Error('Invoice already paid');
379
+ }
380
+ if (invoice.status === 'void') {
381
+ throw new Error('Invoice already void');
382
+ }
383
+ if (invoice.status === 'draft') {
384
+ throw new Error('Invoice is draft');
385
+ }
386
+
387
+ const paymentIntent = await PaymentIntent.findByPk(invoice.payment_intent_id);
388
+ if (!paymentIntent) {
389
+ throw new Error('Payment intent not found for invoice');
390
+ }
391
+ if (paymentIntent.status === 'canceled') {
392
+ throw new Error('Payment intent already canceled');
393
+ }
394
+ if (paymentIntent.status === 'succeeded') {
395
+ throw new Error('Payment intent already succeeded');
396
+ }
397
+ if (paymentIntent.status === 'processing') {
398
+ throw new Error('Payment intent processing');
399
+ }
400
+
401
+ const paymentCurrency = await PaymentCurrency.findByPk(paymentIntent.currency_id);
402
+ const paymentMethod = await PaymentMethod.findByPk(paymentIntent.payment_method_id);
403
+
404
+ return {
405
+ invoice,
406
+ paymentIntent,
407
+ paymentCurrency: paymentCurrency as PaymentCurrency,
408
+ paymentMethod: paymentMethod as PaymentMethod,
409
+ };
410
+ }
@@ -0,0 +1,128 @@
1
+ import { toTypeInfo } from '@arcblock/did';
2
+ import { toDelegateAddress } from '@arcblock/did-util';
3
+ import type { Transaction } from '@ocap/client';
4
+ import { BN } from '@ocap/util';
5
+ import { fromPublicKey } from '@ocap/wallet';
6
+
7
+ import { invoiceQueue } from '../../jobs/invoice';
8
+ import { subscriptionQueue } from '../../jobs/subscription';
9
+ import type { CallbackArgs } from '../../libs/auth';
10
+ import { wallet } from '../../libs/auth';
11
+ import { getClient } from '../../libs/chain/arcblock';
12
+ import { ensureInvoiceForCheckout, ensurePaymentIntent } from './shared';
13
+
14
+ export default {
15
+ action: 'subscription',
16
+ onConnect: async ({ userDid, userPk, extraParams }: CallbackArgs) => {
17
+ const { checkoutSessionId } = extraParams;
18
+ const { checkoutSession, paymentMethod, paymentCurrency, subscription } = await ensurePaymentIntent(
19
+ checkoutSessionId,
20
+ userDid
21
+ );
22
+ if (!subscription) {
23
+ throw new Error('Subscription for checkoutSession not found');
24
+ }
25
+
26
+ // TODO: support multiple chain and multiple currency
27
+ if (paymentMethod.type === 'arcblock') {
28
+ if (checkoutSession.amount_total > '0') {
29
+ const client = getClient(paymentMethod.settings.arcblock?.api_host as string);
30
+ const result = await client.getAccountTokens({ address: userDid, token: paymentCurrency.contract });
31
+ const balance = result.tokens[0]?.balance || '0';
32
+ if (new BN(balance).lt(new BN(checkoutSession.amount_total))) {
33
+ throw new Error(
34
+ `Your account ${userDid} does not have enough ${paymentCurrency.symbol} to complete this subscription`
35
+ );
36
+ }
37
+ }
38
+
39
+ return {
40
+ signature: {
41
+ type: 'DelegateTx',
42
+ description: `Sign the delegation to complete subscription ${subscription.id}`,
43
+ wallet: fromPublicKey(userPk, toTypeInfo(userDid)),
44
+ data: {
45
+ itx: {
46
+ address: toDelegateAddress(userDid, wallet.address),
47
+ to: wallet.address,
48
+ // FIXME: we need to enforce which token can be transferred, and how much, and at what interval on chain
49
+ ops: [{ typeUrl: 'fg:t:transfer_v2', rules: [] }],
50
+ data: {
51
+ type: 'json',
52
+ // @ts-ignore
53
+ value: {
54
+ appId: wallet.address,
55
+ subscriptionId: subscription.id,
56
+ checkoutSessionId,
57
+ },
58
+ },
59
+ },
60
+ },
61
+ chainInfo: {
62
+ host: paymentMethod.settings?.arcblock?.api_host as string,
63
+ id: paymentMethod.settings?.arcblock?.chain_id as string,
64
+ },
65
+ },
66
+ };
67
+ }
68
+
69
+ throw new Error(`Payment method ${paymentMethod.type} not supported`);
70
+ },
71
+ onAuth: async ({ userDid, userPk, claims, request, extraParams }: CallbackArgs) => {
72
+ const { checkoutSessionId } = extraParams;
73
+ const { checkoutSession, customer, paymentMethod, subscription } = await ensurePaymentIntent(
74
+ checkoutSessionId,
75
+ userDid
76
+ );
77
+
78
+ if (!subscription) {
79
+ throw new Error('Subscription for checkoutSession not found');
80
+ }
81
+
82
+ if (paymentMethod.type === 'arcblock') {
83
+ await subscription.update({
84
+ payment_settings: {
85
+ payment_method_types: ['arcblock'],
86
+ payment_method_options: {
87
+ arcblock: { payer: userDid },
88
+ },
89
+ },
90
+ });
91
+
92
+ const { invoice } = await ensureInvoiceForCheckout({ checkoutSession, customer, subscription });
93
+
94
+ const client = getClient(paymentMethod.settings.arcblock?.api_host as string);
95
+ const claim = claims.find((x) => x.type === 'signature');
96
+
97
+ // execute the delegate tx
98
+ const tx: Partial<Transaction> = client.decodeTx(claim.origin);
99
+ const txHash = await client.sendDelegateTx(
100
+ // @ts-ignore
101
+ { tx, wallet: fromPublicKey(userPk, toTypeInfo(userDid)), signature: claim.sig },
102
+ { headers: client.pickGasPayerHeaders(request) }
103
+ );
104
+
105
+ await subscription.update({
106
+ payment_details: {
107
+ tx_hash: txHash,
108
+ payer: userDid,
109
+ },
110
+ });
111
+
112
+ // FIXME: handle error on the invoice payment job
113
+ if (invoice) {
114
+ invoiceQueue.push({ id: invoice.id, job: { invoiceId: invoice.id, retryOnError: false } });
115
+ }
116
+ subscriptionQueue.push({
117
+ id: subscription.id,
118
+ job: { subscriptionId: subscription.id, action: 'cycle' },
119
+ // our next invoice should be generated at the end of current period, either trailing or normal
120
+ runAt: subscription.trail_end || subscription.current_period_end,
121
+ });
122
+
123
+ return { hash: txHash };
124
+ }
125
+
126
+ throw new Error(`Payment method ${paymentMethod.type} not supported`);
127
+ },
128
+ };
@@ -0,0 +1,70 @@
1
+ import { user } from '@blocklet/sdk/lib/middlewares';
2
+ import { Router } from 'express';
3
+ import Joi from 'joi';
4
+ import type { WhereOptions } from 'sequelize';
5
+
6
+ import { authenticate } from '../libs/security';
7
+ import { Customer } from '../store/models/customer';
8
+
9
+ const router = Router();
10
+ const auth = authenticate<Customer>({ component: true, roles: ['owner', 'admin'] });
11
+
12
+ const schema = Joi.object<{
13
+ page: number;
14
+ size: number;
15
+ livemode?: boolean;
16
+ }>({
17
+ page: Joi.number().integer().min(1).default(1),
18
+ size: Joi.number().integer().min(1).max(100).default(20),
19
+ livemode: Joi.boolean().empty(''),
20
+ });
21
+ router.get('/', auth, async (req, res) => {
22
+ const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
23
+ const where: WhereOptions<Customer> = {};
24
+
25
+ if (typeof query.livemode === 'boolean') {
26
+ where.livemode = query.livemode;
27
+ }
28
+
29
+ try {
30
+ const { rows: list, count } = await Customer.findAndCountAll({
31
+ where,
32
+ order: [['created_at', 'DESC']],
33
+ offset: (page - 1) * size,
34
+ limit: size,
35
+ include: [],
36
+ });
37
+
38
+ res.json({ count, list });
39
+ } catch (err) {
40
+ console.error(err);
41
+ res.json({ count: 0, list: [] });
42
+ }
43
+ });
44
+
45
+ // eslint-disable-next-line consistent-return
46
+ router.get('/me', user(), async (req, res) => {
47
+ if (!req.user) {
48
+ return res.status(403).json({ error: 'Unauthorized' });
49
+ }
50
+
51
+ try {
52
+ const doc = await Customer.findByPkOrDid(req.user.did as string);
53
+ res.json(doc);
54
+ } catch (err) {
55
+ console.error(err);
56
+ res.json(null);
57
+ }
58
+ });
59
+
60
+ router.get('/:id', auth, async (req, res) => {
61
+ try {
62
+ const doc = await Customer.findByPkOrDid(req.params.id as string);
63
+ res.json(doc);
64
+ } catch (err) {
65
+ console.error(err);
66
+ res.json(null);
67
+ }
68
+ });
69
+
70
+ export default router;
@@ -0,0 +1,76 @@
1
+ import { Router } from 'express';
2
+ import Joi from 'joi';
3
+ import type { WhereOptions } from 'sequelize';
4
+
5
+ import { authenticate } from '../libs/security';
6
+ import { Event } from '../store/models/event';
7
+
8
+ const router = Router();
9
+ const auth = authenticate<Event>({ component: true, roles: ['owner', 'admin'] });
10
+
11
+ const schema = Joi.object<{
12
+ page: number;
13
+ size: number;
14
+ livemode?: boolean;
15
+ type?: string;
16
+ object_id?: string;
17
+ }>({
18
+ page: Joi.number().integer().min(1).default(1),
19
+ size: Joi.number().integer().min(1).max(100).default(20),
20
+ livemode: Joi.boolean().empty(''),
21
+ type: Joi.string().empty(''),
22
+ object_id: Joi.string().empty(''),
23
+ });
24
+ router.get('/', auth, async (req, res) => {
25
+ const { page, size, ...query } = await schema.validateAsync(req.query, { stripUnknown: true });
26
+ const where: WhereOptions<Event> = {};
27
+
28
+ if (query.type) {
29
+ where.type = query.type
30
+ .split(',')
31
+ .map((x) => x.trim())
32
+ .filter(Boolean);
33
+ }
34
+ if (query.object_id) {
35
+ where.object_id = query.object_id;
36
+ }
37
+ if (typeof query.livemode === 'boolean') {
38
+ where.livemode = query.livemode;
39
+ }
40
+
41
+ try {
42
+ const { rows: list, count } = await Event.findAndCountAll({
43
+ where,
44
+ attributes: { exclude: ['data', 'request'] },
45
+ order: [['created_at', 'DESC']],
46
+ offset: (page - 1) * size,
47
+ limit: size,
48
+ include: [],
49
+ });
50
+
51
+ res.json({ count, list });
52
+ } catch (err) {
53
+ console.error(err);
54
+ res.json({ count: 0, list: [] });
55
+ }
56
+ });
57
+
58
+ router.get('/:id', auth, async (req, res) => {
59
+ try {
60
+ const doc = await Event.findOne({
61
+ where: { id: req.params.id },
62
+ include: [],
63
+ });
64
+
65
+ if (doc) {
66
+ res.json(doc);
67
+ } else {
68
+ res.json(null);
69
+ }
70
+ } catch (err) {
71
+ console.error(err);
72
+ res.json(null);
73
+ }
74
+ });
75
+
76
+ export default router;