payment-kit 1.20.10 → 1.20.12
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 +25 -24
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/invoice.ts +50 -16
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/locales/en.ts +38 -38
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/models/checkout-session.ts +12 -0
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/doc/vendor_fulfillment_system.md +38 -38
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +253 -26
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- package/vite.config.ts +4 -3
package/README.md
CHANGED
|
@@ -1,46 +1,47 @@
|
|
|
1
1
|
# Payment Kit
|
|
2
2
|
|
|
3
|
-
The decentralized
|
|
3
|
+
The decentralized Stripe for the Blocklet platform.
|
|
4
4
|
|
|
5
5
|
## Contribution
|
|
6
6
|
|
|
7
7
|
### Development
|
|
8
8
|
|
|
9
|
-
1.
|
|
10
|
-
2.
|
|
11
|
-
3.
|
|
9
|
+
1. Clone the repository
|
|
10
|
+
2. Run `make build`
|
|
11
|
+
3. Run `cd blocklets/core && blocklet dev`
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
#### Troubleshooting
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**Error: "pre-start error component xxx is not running or unreachable"**
|
|
16
16
|
|
|
17
|
-
-
|
|
18
|
-
-
|
|
19
|
-
-
|
|
20
|
-
-
|
|
21
|
-
-
|
|
17
|
+
- Create a `.env.local` file in the project root
|
|
18
|
+
- Add `BLOCKLET_DEV_APP_DID="did:abt:your payment kit server did"`
|
|
19
|
+
- Add `BLOCKLET_DEV_MOUNT_POINT="/example"`
|
|
20
|
+
- Copy `.env.local` to the `/core` directory
|
|
21
|
+
- Edit `BLOCKLET_DEV_MOUNT_POINT="/"`
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
**Error: "Insufficient funds to pay for transaction cost from xxx, expected 1.0020909, got 0"**
|
|
24
|
+
|
|
25
|
+
- Copy the `BLOCKLET_DEV_APP_DID`
|
|
26
|
+
- Transfer 2 TBA from your DID Wallet to the copied address
|
|
26
27
|
|
|
27
28
|
### Debug Stripe
|
|
28
29
|
|
|
29
|
-
1. Install and
|
|
30
|
-
2. Start your local
|
|
30
|
+
1. Install and log in following the instructions at: https://stripe.com/docs/stripe-cli
|
|
31
|
+
2. Start your local Payment Kit server and note its port
|
|
31
32
|
3. Run `stripe listen --forward-to http://127.0.0.1:8188/api/integrations/stripe/webhook --log-level=debug --latest`
|
|
32
33
|
|
|
33
34
|
### Test Stripe
|
|
34
35
|
|
|
35
|
-
Invoices for subscriptions are not finalized automatically. You can use
|
|
36
|
+
Invoices for subscriptions are not finalized automatically. You can use the Stripe Postman collection to finalize them and then confirm the payment.
|
|
36
37
|
|
|
37
38
|
### Easter Eggs
|
|
38
39
|
|
|
39
|
-
|
|
40
|
+
Non-public environment variables used in the code that can change the behavior of Payment Kit:
|
|
40
41
|
|
|
41
|
-
- PAYMENT_CHANGE_LOCKED_PRICE
|
|
42
|
-
- PAYMENT_RELOAD_SUBSCRIPTION_JOBS
|
|
43
|
-
- PAYMENT_BILLING_THRESHOLD
|
|
44
|
-
- PAYMENT_MIN_STAKE_AMOUNT
|
|
45
|
-
- PAYMENT_DAYS_UNTIL_DUE
|
|
46
|
-
- PAYMENT_DAYS_UNTIL_CANCEL
|
|
42
|
+
- `PAYMENT_CHANGE_LOCKED_PRICE`: Allows changing locked price (must be set to "1" to enable)
|
|
43
|
+
- `PAYMENT_RELOAD_SUBSCRIPTION_JOBS`: Reloads subscription jobs on start (must be set to "1" to enable)
|
|
44
|
+
- `PAYMENT_BILLING_THRESHOLD`: Global default billing threshold (must be a number)
|
|
45
|
+
- `PAYMENT_MIN_STAKE_AMOUNT`: Global minimum stake amount limit (must be a number)
|
|
46
|
+
- `PAYMENT_DAYS_UNTIL_DUE`: Global default days until due (must be a number)
|
|
47
|
+
- `PAYMENT_DAYS_UNTIL_CANCEL`: Global default days until cancellation (must be a number)
|
package/api/src/index.ts
CHANGED
|
@@ -26,6 +26,7 @@ import { initEventBroadcast } from './libs/ws';
|
|
|
26
26
|
import { startCheckoutSessionQueue } from './queues/checkout-session';
|
|
27
27
|
import { startCreditConsumeQueue } from './queues/credit-consume';
|
|
28
28
|
import { startCreditGrantQueue } from './queues/credit-grant';
|
|
29
|
+
import { startDiscountStatusQueue } from './queues/discount-status';
|
|
29
30
|
import { startEventQueue } from './queues/event';
|
|
30
31
|
import { startInvoiceQueue } from './queues/invoice';
|
|
31
32
|
import { startNotificationQueue } from './queues/notification';
|
|
@@ -135,6 +136,7 @@ export const server = app.listen(port, (err?: any) => {
|
|
|
135
136
|
startRefundQueue().then(() => logger.info('refund queue started'));
|
|
136
137
|
startCreditConsumeQueue().then(() => logger.info('credit queue started'));
|
|
137
138
|
startCreditGrantQueue().then(() => logger.info('credit grant queue started'));
|
|
139
|
+
startDiscountStatusQueue().then(() => logger.info('discount status queue started'));
|
|
138
140
|
startUploadBillingInfoListener();
|
|
139
141
|
|
|
140
142
|
if (process.env.BLOCKLET_MODE === 'production') {
|
|
@@ -4,6 +4,7 @@ import pick from 'lodash/pick';
|
|
|
4
4
|
import pWaitFor from 'p-wait-for';
|
|
5
5
|
import type Stripe from 'stripe';
|
|
6
6
|
|
|
7
|
+
import type { WhereOptions } from 'sequelize';
|
|
7
8
|
import { checkUsageReportEmpty } from '../../../libs/subscription';
|
|
8
9
|
import { createEvent } from '../../../libs/audit';
|
|
9
10
|
import { getLock } from '../../../libs/lock';
|
|
@@ -12,6 +13,7 @@ import {
|
|
|
12
13
|
AutoRechargeConfig,
|
|
13
14
|
CheckoutSession,
|
|
14
15
|
Customer,
|
|
16
|
+
Discount,
|
|
15
17
|
Invoice,
|
|
16
18
|
InvoiceItem,
|
|
17
19
|
PaymentMethod,
|
|
@@ -24,6 +26,11 @@ import { handleSubscriptionOnPaymentFailure } from './subscription';
|
|
|
24
26
|
|
|
25
27
|
export async function handleStripeInvoicePaid(invoice: Invoice, event: TEventExpanded) {
|
|
26
28
|
logger.info('invoice paid on stripe event', { locale: invoice.id });
|
|
29
|
+
const processDiscounts = await processInvoiceDiscounts(
|
|
30
|
+
event.data.object,
|
|
31
|
+
invoice.subscription_id || '',
|
|
32
|
+
invoice.checkout_session_id || ''
|
|
33
|
+
);
|
|
27
34
|
await invoice.update({
|
|
28
35
|
status: 'paid',
|
|
29
36
|
...pick(event.data.object, [
|
|
@@ -37,12 +44,38 @@ export async function handleStripeInvoicePaid(invoice: Invoice, event: TEventExp
|
|
|
37
44
|
'subtotal_excluding_tax',
|
|
38
45
|
'subtotal',
|
|
39
46
|
'tax',
|
|
40
|
-
'total_discount_amounts',
|
|
41
47
|
'total',
|
|
42
48
|
]),
|
|
49
|
+
total_discount_amounts: processDiscounts,
|
|
43
50
|
});
|
|
44
51
|
}
|
|
45
52
|
|
|
53
|
+
const processInvoiceDiscounts = async (stripeInvoice: any, subscriptionId: string, checkoutSessionId: string) => {
|
|
54
|
+
const discountFilter: WhereOptions = {};
|
|
55
|
+
if (checkoutSessionId) {
|
|
56
|
+
discountFilter.checkout_session_id = checkoutSessionId;
|
|
57
|
+
}
|
|
58
|
+
if (subscriptionId) {
|
|
59
|
+
discountFilter.subscription_id = subscriptionId;
|
|
60
|
+
}
|
|
61
|
+
const discount =
|
|
62
|
+
stripeInvoice.total_discount_amounts && Object.keys(discountFilter).length > 0
|
|
63
|
+
? await Discount.findOne({
|
|
64
|
+
where: discountFilter,
|
|
65
|
+
})
|
|
66
|
+
: null;
|
|
67
|
+
const processDiscounts = stripeInvoice.total_discount_amounts
|
|
68
|
+
? stripeInvoice.total_discount_amounts.map((d: any) => {
|
|
69
|
+
return {
|
|
70
|
+
stripeDiscount: d.discount,
|
|
71
|
+
discount: discount?.id || d.discount,
|
|
72
|
+
amount: d.amount,
|
|
73
|
+
};
|
|
74
|
+
})
|
|
75
|
+
: null;
|
|
76
|
+
return processDiscounts;
|
|
77
|
+
};
|
|
78
|
+
|
|
46
79
|
export function getStripeInvoicePeriod(invoice: any) {
|
|
47
80
|
const lineItem: TInvoiceItem = (invoice.lines.data || []).find((x: any) => !x.proration && x.type === 'subscription');
|
|
48
81
|
if (lineItem && lineItem.period) {
|
|
@@ -71,6 +104,11 @@ export async function syncStripeInvoice(invoice: Invoice) {
|
|
|
71
104
|
const client = await method.getStripeClient();
|
|
72
105
|
const stripeInvoice = await client.invoices.retrieve(invoice.metadata.stripe_id);
|
|
73
106
|
if (stripeInvoice) {
|
|
107
|
+
const processDiscounts = await processInvoiceDiscounts(
|
|
108
|
+
stripeInvoice,
|
|
109
|
+
invoice.subscription_id || '',
|
|
110
|
+
invoice.checkout_session_id || ''
|
|
111
|
+
);
|
|
74
112
|
await invoice.update(
|
|
75
113
|
// @ts-ignore
|
|
76
114
|
merge(
|
|
@@ -89,7 +127,10 @@ export async function syncStripeInvoice(invoice: Invoice) {
|
|
|
89
127
|
'total_discount_amounts',
|
|
90
128
|
'total',
|
|
91
129
|
]),
|
|
92
|
-
getStripeInvoicePeriod(stripeInvoice)
|
|
130
|
+
getStripeInvoicePeriod(stripeInvoice),
|
|
131
|
+
{
|
|
132
|
+
total_discount_amounts: processDiscounts,
|
|
133
|
+
}
|
|
93
134
|
)
|
|
94
135
|
);
|
|
95
136
|
logger.info('stripe invoice synced', { locale: invoice.id, remote: stripeInvoice.id });
|
|
@@ -122,6 +163,19 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
|
|
|
122
163
|
return invoice;
|
|
123
164
|
}
|
|
124
165
|
|
|
166
|
+
const processTotalDiscounts = await processInvoiceDiscounts(
|
|
167
|
+
stripeInvoice,
|
|
168
|
+
subscription.id,
|
|
169
|
+
checkoutSession?.id || ''
|
|
170
|
+
);
|
|
171
|
+
|
|
172
|
+
let processDiscounts = stripeInvoice.discounts;
|
|
173
|
+
if (stripeInvoice.discounts && stripeInvoice.discounts.length > 0) {
|
|
174
|
+
if (processTotalDiscounts) {
|
|
175
|
+
processDiscounts = processTotalDiscounts.map((d: any) => d?.discount);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
125
179
|
const invoiceNumber = await customer.getInvoiceNumber();
|
|
126
180
|
// @ts-ignore
|
|
127
181
|
invoice = await Invoice.create({
|
|
@@ -157,11 +211,11 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
|
|
|
157
211
|
'subtotal_excluding_tax',
|
|
158
212
|
'subtotal',
|
|
159
213
|
'tax',
|
|
160
|
-
'total_discount_amounts',
|
|
161
214
|
'total',
|
|
162
215
|
'last_finalization_error',
|
|
163
216
|
]),
|
|
164
|
-
|
|
217
|
+
discounts: processDiscounts,
|
|
218
|
+
total_discount_amounts: processTotalDiscounts,
|
|
165
219
|
currency_id: subscription.currency_id,
|
|
166
220
|
customer_id: subscription.customer_id,
|
|
167
221
|
default_payment_method_id: subscription.default_payment_method_id as string,
|
|
@@ -173,6 +227,7 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
|
|
|
173
227
|
payment_settings: subscription.payment_settings,
|
|
174
228
|
metadata: {
|
|
175
229
|
stripe_id: stripeInvoice.id,
|
|
230
|
+
stripe_discounts: stripeInvoice.discounts,
|
|
176
231
|
},
|
|
177
232
|
});
|
|
178
233
|
if (checkoutSession) {
|
|
@@ -216,7 +271,10 @@ export async function ensureStripeInvoice(stripeInvoice: any, subscription: Subs
|
|
|
216
271
|
},
|
|
217
272
|
});
|
|
218
273
|
|
|
219
|
-
logger.info('stripe invoice items mirrored', {
|
|
274
|
+
logger.info('stripe invoice items mirrored', {
|
|
275
|
+
local: item.id,
|
|
276
|
+
remote: line.id,
|
|
277
|
+
});
|
|
220
278
|
return item;
|
|
221
279
|
})
|
|
222
280
|
);
|
|
@@ -132,6 +132,7 @@ const waitForStripePaymentMirrored = (stripeInvoiceId: string) => {
|
|
|
132
132
|
|
|
133
133
|
export async function handlePaymentIntentEvent(event: TEventExpanded, client: Stripe) {
|
|
134
134
|
const localIntentId = event.data.object.metadata?.id;
|
|
135
|
+
|
|
135
136
|
if (!localIntentId) {
|
|
136
137
|
// We only handle payment_intents created from subscriptions
|
|
137
138
|
if (event.data.object.invoice) {
|
|
@@ -7,13 +7,14 @@ import pick from 'lodash/pick';
|
|
|
7
7
|
import { Op } from 'sequelize';
|
|
8
8
|
|
|
9
9
|
import logger from '../../libs/logger';
|
|
10
|
-
import { getPriceUintAmountByCurrency } from '../../libs/session';
|
|
11
10
|
import { getSubscriptionItemPrice } from '../../libs/subscription';
|
|
12
11
|
import { sleep } from '../../libs/util';
|
|
13
12
|
import {
|
|
14
13
|
AutoRechargeConfig,
|
|
15
14
|
CheckoutSession,
|
|
16
15
|
Customer,
|
|
16
|
+
Discount,
|
|
17
|
+
Coupon,
|
|
17
18
|
Invoice,
|
|
18
19
|
InvoiceItem,
|
|
19
20
|
PaymentCurrency,
|
|
@@ -28,6 +29,7 @@ import {
|
|
|
28
29
|
import { syncStripeInvoice } from './handlers/invoice';
|
|
29
30
|
import { syncStripePayment } from './handlers/payment-intent';
|
|
30
31
|
import { getLock } from '../../libs/lock';
|
|
32
|
+
import { getPriceUintAmountByCurrency } from '../../libs/price';
|
|
31
33
|
|
|
32
34
|
export async function ensureStripeProduct(internal: Product, method: PaymentMethod) {
|
|
33
35
|
const client = method.getStripeClient();
|
|
@@ -281,7 +283,8 @@ export async function ensureStripeSubscription(
|
|
|
281
283
|
stripeSubscription = await client.subscriptions.retrieve(internal.payment_details.stripe.subscription_id, {
|
|
282
284
|
expand: ['latest_invoice.payment_intent', 'pending_setup_intent'],
|
|
283
285
|
});
|
|
284
|
-
//
|
|
286
|
+
// Handle subscription discount updates
|
|
287
|
+
await updateSubscriptionDiscounts(client, stripeSubscription, internal, method, currency);
|
|
285
288
|
} else {
|
|
286
289
|
const customer = await ensureStripePaymentCustomer(internal, method);
|
|
287
290
|
const prices = await Promise.all(
|
|
@@ -327,6 +330,17 @@ export async function ensureStripeSubscription(
|
|
|
327
330
|
props.trial_end = trialEnd;
|
|
328
331
|
}
|
|
329
332
|
|
|
333
|
+
// Handle discounts for subscription
|
|
334
|
+
const discounts = await getSubscriptionDiscounts(internal, method, currency);
|
|
335
|
+
if (discounts.length > 0) {
|
|
336
|
+
props.discounts = discounts;
|
|
337
|
+
logger.info('Applied discounts to Stripe subscription', {
|
|
338
|
+
subscription_id: internal.id,
|
|
339
|
+
discounts: discounts.map((d) => d.coupon),
|
|
340
|
+
discount_count: discounts.length,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
330
344
|
stripeSubscription = await client.subscriptions.create(props);
|
|
331
345
|
logger.info('stripe subscription created', { local: internal.id, remote: stripeSubscription.id });
|
|
332
346
|
|
|
@@ -400,6 +414,243 @@ export async function forwardUsageRecordToStripe(
|
|
|
400
414
|
return result;
|
|
401
415
|
}
|
|
402
416
|
|
|
417
|
+
/**
|
|
418
|
+
* Get all discounts associated with a subscription and create Stripe coupons
|
|
419
|
+
*/
|
|
420
|
+
async function getSubscriptionDiscounts(
|
|
421
|
+
subscription: Subscription,
|
|
422
|
+
method: PaymentMethod,
|
|
423
|
+
currency: PaymentCurrency
|
|
424
|
+
): Promise<{ coupon: string }[]> {
|
|
425
|
+
const discounts = (await Discount.findAll({
|
|
426
|
+
where: {
|
|
427
|
+
subscription_id: subscription.id,
|
|
428
|
+
},
|
|
429
|
+
include: [
|
|
430
|
+
{
|
|
431
|
+
model: Coupon,
|
|
432
|
+
as: 'coupon',
|
|
433
|
+
required: true,
|
|
434
|
+
where: {
|
|
435
|
+
valid: true,
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
],
|
|
439
|
+
})) as (Discount & { coupon: Coupon })[];
|
|
440
|
+
|
|
441
|
+
const stripeDiscounts = [];
|
|
442
|
+
|
|
443
|
+
for (const discount of discounts) {
|
|
444
|
+
try {
|
|
445
|
+
const stripeCouponId = await createStripeSubscriptionCoupon(discount.coupon, subscription.id, method, currency);
|
|
446
|
+
stripeDiscounts.push({ coupon: stripeCouponId });
|
|
447
|
+
|
|
448
|
+
logger.info('Created Stripe coupon for subscription', {
|
|
449
|
+
local_coupon: discount.coupon_id,
|
|
450
|
+
stripe_coupon: stripeCouponId,
|
|
451
|
+
subscription_id: subscription.id,
|
|
452
|
+
});
|
|
453
|
+
} catch (error) {
|
|
454
|
+
logger.error('Failed to create Stripe coupon for subscription', {
|
|
455
|
+
discount_id: discount.id,
|
|
456
|
+
coupon_id: discount.coupon_id,
|
|
457
|
+
subscription_id: subscription.id,
|
|
458
|
+
error: error.message,
|
|
459
|
+
});
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
return stripeDiscounts;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Update subscription discounts by managing existing and new discounts
|
|
468
|
+
* Since Stripe doesn't allow direct discount updates on subscriptions,
|
|
469
|
+
* we need to use discount deletion and application approach
|
|
470
|
+
*/
|
|
471
|
+
async function updateSubscriptionDiscounts(
|
|
472
|
+
client: any,
|
|
473
|
+
stripeSubscription: any,
|
|
474
|
+
subscription: Subscription,
|
|
475
|
+
method: PaymentMethod,
|
|
476
|
+
currency: PaymentCurrency
|
|
477
|
+
): Promise<void> {
|
|
478
|
+
try {
|
|
479
|
+
const newDiscounts = await getSubscriptionDiscounts(subscription, method, currency);
|
|
480
|
+
const existingDiscounts = stripeSubscription.discounts || [];
|
|
481
|
+
|
|
482
|
+
const existingCouponIds = existingDiscounts.map((d: any) => d.coupon?.id).filter(Boolean);
|
|
483
|
+
const newCouponIds = newDiscounts.map((d: any) => d.coupon);
|
|
484
|
+
|
|
485
|
+
const hasChanges =
|
|
486
|
+
existingCouponIds.length !== newCouponIds.length ||
|
|
487
|
+
!existingCouponIds.every((id: string) => newCouponIds.includes(id));
|
|
488
|
+
|
|
489
|
+
if (!hasChanges) {
|
|
490
|
+
logger.info('No discount changes needed for subscription', {
|
|
491
|
+
subscription_id: subscription.id,
|
|
492
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
493
|
+
});
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Remove existing discounts (Stripe subscriptions typically have only one discount)
|
|
498
|
+
if (existingDiscounts.length > 0) {
|
|
499
|
+
try {
|
|
500
|
+
await client.subscriptions.deleteDiscount(stripeSubscription.id);
|
|
501
|
+
logger.info('Removed existing discounts from subscription', {
|
|
502
|
+
subscription_id: subscription.id,
|
|
503
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
504
|
+
removed_count: existingDiscounts.length,
|
|
505
|
+
});
|
|
506
|
+
} catch (error) {
|
|
507
|
+
logger.error('Failed to remove discounts from subscription', {
|
|
508
|
+
subscription_id: subscription.id,
|
|
509
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
510
|
+
error: error.message,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Apply new discounts using the discounts field
|
|
516
|
+
if (newDiscounts.length > 0) {
|
|
517
|
+
try {
|
|
518
|
+
await client.subscriptions.update(stripeSubscription.id, {
|
|
519
|
+
discounts: newDiscounts,
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
logger.info('Applied new discounts to subscription', {
|
|
523
|
+
subscription_id: subscription.id,
|
|
524
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
525
|
+
discounts: newDiscounts.map((d) => d.coupon),
|
|
526
|
+
discount_count: newDiscounts.length,
|
|
527
|
+
});
|
|
528
|
+
} catch (error) {
|
|
529
|
+
logger.error('Failed to apply discounts to subscription', {
|
|
530
|
+
subscription_id: subscription.id,
|
|
531
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
532
|
+
discounts: newDiscounts,
|
|
533
|
+
error: error.message,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
} catch (error) {
|
|
538
|
+
logger.error('Failed to update subscription discounts', {
|
|
539
|
+
subscription_id: subscription.id,
|
|
540
|
+
stripe_subscription_id: stripeSubscription.id,
|
|
541
|
+
error: error.message,
|
|
542
|
+
});
|
|
543
|
+
throw error;
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Create a Stripe coupon specifically for a subscription with max_redemptions = 1
|
|
549
|
+
*/
|
|
550
|
+
async function createStripeSubscriptionCoupon(
|
|
551
|
+
localCoupon: Coupon,
|
|
552
|
+
subscriptionId: string,
|
|
553
|
+
method: PaymentMethod,
|
|
554
|
+
currency?: PaymentCurrency
|
|
555
|
+
): Promise<string> {
|
|
556
|
+
const client = method.getStripeClient();
|
|
557
|
+
|
|
558
|
+
// Check if coupon already exists for this subscription
|
|
559
|
+
const existingCouponId = `${localCoupon.id}_sub_${subscriptionId.slice(-8)}`;
|
|
560
|
+
try {
|
|
561
|
+
const existingCoupon = await client.coupons.retrieve(existingCouponId);
|
|
562
|
+
if (existingCoupon) {
|
|
563
|
+
logger.info('Stripe coupon already exists for subscription', {
|
|
564
|
+
local_coupon_id: localCoupon.id,
|
|
565
|
+
stripe_coupon_id: existingCoupon.id,
|
|
566
|
+
subscription_id: subscriptionId,
|
|
567
|
+
});
|
|
568
|
+
return existingCoupon.id;
|
|
569
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
logger.error('Failed to retrieve Stripe coupon for subscription', {
|
|
572
|
+
local_coupon_id: localCoupon.id,
|
|
573
|
+
subscription_id: subscriptionId,
|
|
574
|
+
error,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
// Prepare coupon data
|
|
578
|
+
const couponData: any = {
|
|
579
|
+
id: existingCouponId, // Use predictable ID
|
|
580
|
+
name: `sub_coupon_${localCoupon.id}`,
|
|
581
|
+
duration: localCoupon.duration as 'once' | 'forever' | 'repeating',
|
|
582
|
+
max_redemptions: 1, // Limit to this subscription only
|
|
583
|
+
metadata: {
|
|
584
|
+
appPid: env.appPid,
|
|
585
|
+
local_coupon_id: localCoupon.id,
|
|
586
|
+
subscription_id: subscriptionId,
|
|
587
|
+
created_for: 'subscription',
|
|
588
|
+
},
|
|
589
|
+
};
|
|
590
|
+
|
|
591
|
+
// Add duration in months for repeating coupons
|
|
592
|
+
if (localCoupon.duration === 'repeating' && localCoupon.duration_in_months) {
|
|
593
|
+
couponData.duration_in_months = localCoupon.duration_in_months;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// Add discount amount - either percent_off or amount_off
|
|
597
|
+
if (localCoupon.percent_off && localCoupon.percent_off > 0) {
|
|
598
|
+
couponData.percent_off = localCoupon.percent_off;
|
|
599
|
+
} else if (localCoupon.amount_off && localCoupon.amount_off !== '0') {
|
|
600
|
+
couponData.amount_off = Number(localCoupon.amount_off || '0');
|
|
601
|
+
|
|
602
|
+
// Get currency for fixed amount discounts
|
|
603
|
+
let targetCurrency = currency;
|
|
604
|
+
if (!targetCurrency && localCoupon.currency_id) {
|
|
605
|
+
targetCurrency = (await PaymentCurrency.findByPk(localCoupon.currency_id)) || undefined;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (targetCurrency) {
|
|
609
|
+
couponData.currency = targetCurrency.symbol.toLowerCase();
|
|
610
|
+
} else {
|
|
611
|
+
// Fallback to USD if no currency specified
|
|
612
|
+
couponData.currency = 'usd';
|
|
613
|
+
logger.warn('No currency specified for fixed amount coupon, defaulting to USD', {
|
|
614
|
+
local_coupon_id: localCoupon.id,
|
|
615
|
+
subscription_id: subscriptionId,
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
} else {
|
|
619
|
+
// Default to 0% discount if neither percent_off nor amount_off is set
|
|
620
|
+
couponData.percent_off = 0;
|
|
621
|
+
logger.warn('Coupon has no discount amount set, defaulting to 0%', {
|
|
622
|
+
local_coupon_id: localCoupon.id,
|
|
623
|
+
subscription_id: subscriptionId,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
const stripeCoupon = await client.coupons.create(couponData);
|
|
629
|
+
|
|
630
|
+
logger.info('Created Stripe coupon for subscription', {
|
|
631
|
+
local_coupon_id: localCoupon.id,
|
|
632
|
+
stripe_coupon_id: stripeCoupon.id,
|
|
633
|
+
subscription_id: subscriptionId,
|
|
634
|
+
coupon_name: stripeCoupon.name,
|
|
635
|
+
duration: stripeCoupon.duration,
|
|
636
|
+
percent_off: stripeCoupon.percent_off,
|
|
637
|
+
amount_off: stripeCoupon.amount_off,
|
|
638
|
+
currency: stripeCoupon.currency,
|
|
639
|
+
max_redemptions: stripeCoupon.max_redemptions,
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
return stripeCoupon.id;
|
|
643
|
+
} catch (error) {
|
|
644
|
+
logger.error('Failed to create Stripe coupon for subscription', {
|
|
645
|
+
local_coupon_id: localCoupon.id,
|
|
646
|
+
subscription_id: subscriptionId,
|
|
647
|
+
coupon_data: couponData,
|
|
648
|
+
error,
|
|
649
|
+
});
|
|
650
|
+
throw error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
403
654
|
export async function batchHandleStripeInvoices() {
|
|
404
655
|
const stripeMethods = await PaymentMethod.findAll({ where: { type: 'stripe' } });
|
|
405
656
|
if (stripeMethods.length === 0) {
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { fromTokenToUnit, fromUnitToToken } from '@ocap/util';
|
|
2
|
+
import { PaymentCurrency } from '../store/models';
|
|
3
|
+
import { trimDecimals } from './math-utils';
|
|
4
|
+
|
|
5
|
+
export async function formatCurrencyToken(amount: string, currencyId: string) {
|
|
6
|
+
if (!amount) {
|
|
7
|
+
return '0';
|
|
8
|
+
}
|
|
9
|
+
if (!currencyId) {
|
|
10
|
+
return amount;
|
|
11
|
+
}
|
|
12
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
13
|
+
if (!currency) {
|
|
14
|
+
throw new Error(`Currency ${currencyId} not found`);
|
|
15
|
+
}
|
|
16
|
+
return fromUnitToToken(trimDecimals(amount || '0', currency.decimal), currency.decimal);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function formatCurrencyUnit(amount: string, currencyId: string) {
|
|
20
|
+
if (!amount) {
|
|
21
|
+
return '0';
|
|
22
|
+
}
|
|
23
|
+
if (!currencyId) {
|
|
24
|
+
return amount;
|
|
25
|
+
}
|
|
26
|
+
const currency = await PaymentCurrency.findByPk(currencyId);
|
|
27
|
+
if (!currency) {
|
|
28
|
+
throw new Error(`Currency ${currencyId} not found`);
|
|
29
|
+
}
|
|
30
|
+
return fromTokenToUnit(amount || '0', currency.decimal).toString();
|
|
31
|
+
}
|