payment-kit 1.29.1 → 1.29.2
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/api/dev.ts +41 -2
- package/api/hono.d.ts +42 -0
- package/api/node-sqlite.d.ts +12 -0
- package/api/src/bootstrap.ts +36 -0
- package/api/src/crons/base.ts +3 -3
- package/api/src/crons/currency.ts +1 -1
- package/api/src/crons/index.ts +27 -24
- package/api/src/crons/metering-subscription-detection.ts +1 -1
- package/api/src/crons/overdue-detection.ts +2 -2
- package/api/src/crons/retry-pending-events.ts +6 -0
- package/api/src/index.ts +22 -161
- package/api/src/integrations/app-store/client.ts +3 -4
- package/api/src/integrations/app-store/handlers/subscription.ts +7 -7
- package/api/src/integrations/app-store/signed-data-verifier.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +21 -7
- package/api/src/integrations/google-play/handlers/subscription.ts +6 -6
- package/api/src/integrations/google-play/handlers/voided.ts +2 -2
- package/api/src/integrations/google-play/verify.ts +3 -2
- package/api/src/integrations/iap-reconcile.ts +3 -5
- package/api/src/integrations/stripe/handlers/invoice.ts +2 -2
- package/api/src/integrations/stripe/handlers/subscription.ts +3 -3
- package/api/src/libs/archive/query.ts +19 -0
- package/api/src/libs/audit.ts +61 -4
- package/api/src/libs/auth.ts +99 -38
- package/api/src/libs/context.ts +78 -1
- package/api/src/libs/currency.ts +2 -2
- package/api/src/libs/dayjs.ts +8 -2
- package/api/src/libs/drivers/auth-storage.ts +118 -0
- package/api/src/libs/drivers/cron.ts +264 -0
- package/api/src/libs/drivers/db.ts +170 -0
- package/api/src/libs/drivers/identity.ts +81 -0
- package/api/src/libs/drivers/index.ts +40 -0
- package/api/src/libs/drivers/locks.ts +226 -0
- package/api/src/libs/drivers/migrate-runner.ts +70 -0
- package/api/src/libs/drivers/queue.ts +104 -0
- package/api/src/libs/drivers/secrets.ts +194 -0
- package/api/src/libs/env.ts +170 -54
- package/api/src/libs/exchange-rate/service.ts +7 -6
- package/api/src/libs/http-fetch-adapter.ts +50 -0
- package/api/src/libs/invoice.ts +1 -1
- package/api/src/libs/lock.ts +51 -47
- package/api/src/libs/logger.ts +48 -8
- package/api/src/libs/notification/index.ts +1 -1
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +2 -1
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -1
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -1
- package/api/src/libs/overdraft-protection.ts +1 -1
- package/api/src/libs/payout.ts +1 -1
- package/api/src/libs/queue/index.ts +259 -52
- package/api/src/libs/queue/runtime.ts +175 -0
- package/api/src/libs/resource.ts +3 -3
- package/api/src/libs/secrets.ts +38 -0
- package/api/src/libs/session.ts +3 -2
- package/api/src/libs/subscription.ts +5 -5
- package/api/src/libs/tenant.ts +92 -0
- package/api/src/libs/url.ts +3 -3
- package/api/src/libs/util.ts +21 -13
- package/api/src/middlewares/hono/cdn.ts +63 -0
- package/api/src/middlewares/hono/context.ts +73 -0
- package/api/src/middlewares/hono/csrf.ts +72 -0
- package/api/src/middlewares/hono/fallback.ts +194 -0
- package/api/src/middlewares/hono/pipeline.ts +73 -0
- package/api/src/middlewares/hono/resource-mount.ts +42 -0
- package/api/src/middlewares/hono/resource.ts +63 -0
- package/api/src/middlewares/hono/security.ts +214 -0
- package/api/src/middlewares/hono/session.ts +114 -0
- package/api/src/middlewares/hono/xss.ts +61 -0
- package/api/src/queues/auto-recharge.ts +12 -10
- package/api/src/queues/checkout-session.ts +17 -12
- package/api/src/queues/credit-consume.ts +40 -36
- package/api/src/queues/credit-grant.ts +25 -18
- package/api/src/queues/credit-reconciliation.ts +7 -5
- package/api/src/queues/discount-status.ts +9 -6
- package/api/src/queues/event.ts +12 -4
- package/api/src/queues/exchange-rate-health.ts +49 -30
- package/api/src/queues/invoice.ts +18 -15
- package/api/src/queues/notification.ts +14 -7
- package/api/src/queues/payment.ts +41 -28
- package/api/src/queues/payout.ts +9 -5
- package/api/src/queues/refund.ts +18 -12
- package/api/src/queues/subscription.ts +83 -53
- package/api/src/queues/token-transfer.ts +15 -10
- package/api/src/queues/usage-record.ts +8 -5
- package/api/src/queues/vendors/commission.ts +7 -5
- package/api/src/queues/vendors/fulfillment-coordinator.ts +17 -13
- package/api/src/queues/vendors/fulfillment.ts +4 -2
- package/api/src/queues/vendors/return-processor.ts +5 -3
- package/api/src/queues/vendors/return-scanner.ts +5 -4
- package/api/src/queues/vendors/status-check.ts +10 -7
- package/api/src/queues/webhook.ts +60 -32
- package/api/src/routes/connect/shared.ts +1 -2
- package/api/src/routes/connect/subscribe.ts +3 -3
- package/api/src/routes/{archive.ts → hono/archive.ts} +69 -64
- package/api/src/routes/{auto-recharge-configs.ts → hono/auto-recharge-configs.ts} +39 -28
- package/api/src/routes/{checkout-sessions.ts → hono/checkout-sessions.ts} +790 -923
- package/api/src/routes/{coupons.ts → hono/coupons.ts} +93 -76
- package/api/src/routes/{credit-grants.ts → hono/credit-grants.ts} +140 -126
- package/api/src/routes/hono/credit-tokens.ts +43 -0
- package/api/src/routes/{credit-transactions.ts → hono/credit-transactions.ts} +37 -29
- package/api/src/routes/{customers.ts → hono/customers.ts} +193 -223
- package/api/src/routes/{donations.ts → hono/donations.ts} +41 -32
- package/api/src/routes/{entitlements.ts → hono/entitlements.ts} +28 -25
- package/api/src/routes/{events.ts → hono/events.ts} +107 -71
- package/api/src/routes/{exchange-rate-providers.ts → hono/exchange-rate-providers.ts} +138 -126
- package/api/src/routes/hono/exchange-rates.ts +77 -0
- package/api/src/routes/hono/index.ts +115 -0
- package/api/src/routes/{integrations → hono/integrations}/app-store.ts +68 -48
- package/api/src/routes/{integrations → hono/integrations}/google-play.ts +78 -58
- package/api/src/routes/hono/integrations/stripe.ts +74 -0
- package/api/src/routes/{invoices.ts → hono/invoices.ts} +253 -244
- package/api/src/routes/{meter-events.ts → hono/meter-events.ts} +120 -110
- package/api/src/routes/hono/meters.ts +288 -0
- package/api/src/routes/hono/passports.ts +73 -0
- package/api/src/routes/{payment-currencies.ts → hono/payment-currencies.ts} +219 -197
- package/api/src/routes/{payment-intents.ts → hono/payment-intents.ts} +136 -132
- package/api/src/routes/{payment-links.ts → hono/payment-links.ts} +145 -128
- package/api/src/routes/{payment-methods.ts → hono/payment-methods.ts} +125 -93
- package/api/src/routes/{payment-stats.ts → hono/payment-stats.ts} +30 -25
- package/api/src/routes/{payouts.ts → hono/payouts.ts} +55 -47
- package/api/src/routes/{prices.ts → hono/prices.ts} +265 -242
- package/api/src/routes/{pricing-table.ts → hono/pricing-table.ts} +94 -87
- package/api/src/routes/{products.ts → hono/products.ts} +172 -159
- package/api/src/routes/{promotion-codes.ts → hono/promotion-codes.ts} +207 -185
- package/api/src/routes/hono/redirect.ts +24 -0
- package/api/src/routes/{refunds.ts → hono/refunds.ts} +96 -80
- package/api/src/routes/{settings.ts → hono/settings.ts} +64 -55
- package/api/src/routes/{subscription-items.ts → hono/subscription-items.ts} +64 -57
- package/api/src/routes/{subscriptions.ts → hono/subscriptions.ts} +475 -528
- package/api/src/routes/{tax-rates.ts → hono/tax-rates.ts} +71 -70
- package/api/src/routes/hono/tool.ts +69 -0
- package/api/src/routes/{usage-records.ts → hono/usage-records.ts} +47 -42
- package/api/src/routes/{vendor.ts → hono/vendor.ts} +315 -167
- package/api/src/routes/{webhook-attempts.ts → hono/webhook-attempts.ts} +17 -13
- package/api/src/routes/hono/webhook-endpoints.ts +126 -0
- package/api/src/service.ts +667 -0
- package/api/src/store/migrations/20230911-seeding.ts +2 -1
- package/api/src/store/migrations/20260609-remove-did-space-jobs.ts +23 -0
- package/api/src/store/migrations/20260610-tenant-columns.ts +40 -0
- package/api/src/store/migrations/20260611-tenant-backfill.ts +33 -0
- package/api/src/store/models/auto-recharge-config.ts +22 -10
- package/api/src/store/models/checkout-session.ts +15 -14
- package/api/src/store/models/coupon.ts +29 -20
- package/api/src/store/models/credit-grant.ts +38 -29
- package/api/src/store/models/credit-transaction.ts +32 -21
- package/api/src/store/models/customer.ts +19 -17
- package/api/src/store/models/discount.ts +11 -2
- package/api/src/store/models/entitlement-grant.ts +21 -9
- package/api/src/store/models/entitlement-product.ts +21 -9
- package/api/src/store/models/entitlement.ts +19 -10
- package/api/src/store/models/event.ts +18 -9
- package/api/src/store/models/exchange-rate-provider.ts +17 -4
- package/api/src/store/models/invoice-item.ts +18 -9
- package/api/src/store/models/invoice.ts +16 -8
- package/api/src/store/models/meter-event.ts +27 -9
- package/api/src/store/models/meter.ts +31 -22
- package/api/src/store/models/payment-currency.ts +25 -8
- package/api/src/store/models/payment-intent.ts +15 -6
- package/api/src/store/models/payment-link.ts +15 -6
- package/api/src/store/models/payment-method.ts +38 -22
- package/api/src/store/models/payment-stat.ts +18 -9
- package/api/src/store/models/payout.ts +15 -6
- package/api/src/store/models/price-quote.ts +17 -8
- package/api/src/store/models/price.ts +24 -12
- package/api/src/store/models/pricing-table.ts +29 -20
- package/api/src/store/models/product-vendor.ts +20 -10
- package/api/src/store/models/product.ts +15 -6
- package/api/src/store/models/promotion-code.ts +14 -6
- package/api/src/store/models/refund.ts +15 -6
- package/api/src/store/models/revenue-snapshot.ts +21 -9
- package/api/src/store/models/setting.ts +18 -9
- package/api/src/store/models/setup-intent.ts +36 -27
- package/api/src/store/models/subscription-item.ts +21 -9
- package/api/src/store/models/subscription-schedule.ts +21 -9
- package/api/src/store/models/subscription.ts +21 -10
- package/api/src/store/models/tax-rate.ts +29 -21
- package/api/src/store/models/usage-record.ts +11 -2
- package/api/src/store/models/webhook-attempt.ts +18 -9
- package/api/src/store/models/webhook-endpoint.ts +18 -9
- package/api/src/store/scoped-core.ts +55 -0
- package/api/src/store/scoped.ts +247 -0
- package/api/src/store/sequelize.ts +66 -22
- package/api/src/store/sql-migrations.ts +20 -0
- package/api/src/store/tenant-backfill.ts +260 -0
- package/api/src/store/tenant-model.ts +124 -0
- package/api/src/store/tenant-tables.ts +50 -0
- package/api/tests/embedded/embedded-multi-mode-d3.spec.ts +257 -0
- package/api/tests/fixtures/bare-query-violation.ts +13 -0
- package/api/tests/fixtures/core-env-violation.ts +10 -0
- package/api/tests/fixtures/host-read-violation.ts +19 -0
- package/api/tests/fixtures/tenants.ts +4 -0
- package/api/tests/integrations/iap-tenant.spec.ts +284 -0
- package/api/tests/libs/archive-query.spec.ts +26 -0
- package/api/tests/libs/audit-tenant.spec.ts +153 -0
- package/api/tests/libs/context.spec.ts +204 -0
- package/api/tests/libs/core-config.spec.ts +115 -0
- package/api/tests/libs/cron-driver-d2.spec.ts +237 -0
- package/api/tests/libs/crons-conservation-d2.spec.ts +52 -0
- package/api/tests/libs/lock-tenant.spec.ts +66 -0
- package/api/tests/libs/scoped.spec.ts +222 -0
- package/api/tests/libs/secrets-facade.spec.ts +52 -0
- package/api/tests/libs/tenancy-slot-authority.spec.ts +209 -0
- package/api/tests/libs/tenant-middleware.spec.ts +42 -0
- package/api/tests/libs/tenant-scanner.spec.ts +120 -0
- package/api/tests/middlewares/hono/cdn.spec.ts +70 -0
- package/api/tests/middlewares/hono/context.spec.ts +113 -0
- package/api/tests/middlewares/hono/csrf.spec.ts +136 -0
- package/api/tests/middlewares/hono/fallback.spec.ts +67 -0
- package/api/tests/middlewares/hono/pipeline.spec.ts +47 -0
- package/api/tests/middlewares/hono/security.spec.ts +181 -0
- package/api/tests/middlewares/hono/session.spec.ts +42 -0
- package/api/tests/middlewares/hono/xss.spec.ts +81 -0
- package/api/tests/models/tenant-backfill.spec.ts +287 -0
- package/api/tests/models/tenant-columns-model.spec.ts +46 -0
- package/api/tests/models/tenant-columns.spec.ts +161 -0
- package/api/tests/queues/credit-consume-batch.spec.ts +8 -1
- package/api/tests/queues/credit-consume.spec.ts +8 -1
- package/api/tests/queues/event-tenant.spec.ts +236 -0
- package/api/tests/queues/exchange-rate-health-tenant-d6.spec.ts +62 -0
- package/api/tests/queues/queue-parity.spec.ts +249 -0
- package/api/tests/queues/queue-runtime-surface.spec.ts +277 -0
- package/api/tests/queues/queue-teardown-d2.spec.ts +127 -0
- package/api/tests/queues/tenant-matrix-a.spec.ts +245 -0
- package/api/tests/queues/tenant-matrix-b.spec.ts +168 -0
- package/api/tests/routes/connect/hono-attach.spec.ts +107 -0
- package/api/tests/service/collapse.spec.ts +96 -0
- package/api/tests/store/tenant-crosscut.spec.ts +202 -0
- package/api/tests/store/tenant-model-spike.spec.ts +177 -0
- package/api/tests/store/tenant-model.spec.ts +162 -0
- package/api/tests/store/tenant-residual.spec.ts +196 -0
- package/api/third.d.ts +4 -0
- package/blocklet.yml +1 -1
- package/cloudflare/README.md +26 -6
- package/cloudflare/build.ts +28 -13
- package/cloudflare/did-connect-auth.ts +0 -217
- package/cloudflare/migrations/0006_tenant_columns.sql +46 -0
- package/cloudflare/migrations/0007_tenant_backfill_indexes.sql +65 -0
- package/cloudflare/migrations/0008_schema_parity.sql +16 -0
- package/cloudflare/migrations/0009_remove_did_space_jobs.sql +5 -0
- package/cloudflare/queue-runtime-mode.ts +13 -0
- package/cloudflare/run-build.js +10 -56
- package/cloudflare/shims/blocklet-sdk/asset-host-transformer.ts +20 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -1
- package/cloudflare/shims/blocklet-sdk/login.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/service-api.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +4 -2
- package/cloudflare/shims/blocklet-sdk/util-constants.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/util-csrf.ts +13 -0
- package/cloudflare/shims/blocklet-sdk/util-wallet.ts +8 -0
- package/cloudflare/shims/cron.ts +38 -158
- package/cloudflare/shims/events.ts +124 -0
- package/cloudflare/shims/fastq.ts +15 -1
- package/cloudflare/shims/nedb-storage.ts +16 -8
- package/cloudflare/shims/xss.ts +8 -0
- package/cloudflare/tenant-middleware.ts +36 -0
- package/cloudflare/tests/tenant-middleware.spec.ts +160 -0
- package/cloudflare/tests/worker-handler-gate.spec.ts +44 -0
- package/cloudflare/worker.ts +204 -433
- package/cloudflare/wrangler.local-e2e.jsonc +26 -0
- package/jest.config.js +3 -1
- package/package.json +33 -38
- package/scripts/core-env-whitelist.json +1 -0
- package/scripts/e2e-12b-runtime.ts +149 -0
- package/scripts/e2e-core-config.ts +125 -0
- package/scripts/e2e-d1-tenancy.ts +116 -0
- package/scripts/e2e-d2-cron-queue.ts +139 -0
- package/scripts/e2e-d3-embedded-multi.ts +171 -0
- package/scripts/e2e-hono-s2.ts +125 -0
- package/scripts/e2e-hono-s3e.ts +135 -0
- package/scripts/e2e-hono-s4.ts +114 -0
- package/scripts/e2e-migration-contract.ts +100 -0
- package/scripts/e2e-s0.ts +61 -0
- package/scripts/e2e-s1.ts +107 -0
- package/scripts/e2e-s2.ts +178 -0
- package/scripts/e2e-s3.ts +110 -0
- package/scripts/e2e-s4.ts +191 -0
- package/scripts/e2e-s5.ts +139 -0
- package/scripts/e2e-s6.ts +127 -0
- package/scripts/e2e-tenant-model.ts +119 -0
- package/scripts/e2e-tenant-worker.ts +199 -0
- package/scripts/gen-sql-migrations.js +46 -0
- package/scripts/phase8-codemod.js +219 -0
- package/scripts/phase9a-env-getters-codemod.js +82 -0
- package/scripts/scan-core-env.js +109 -0
- package/scripts/scan-tenant-queries.js +235 -0
- package/scripts/schema-drift-guard.ts +210 -0
- package/scripts/tenant-scan-whitelist.json +1 -0
- package/src/env.d.ts +13 -1
- package/tsconfig.json +1 -1
- package/api/src/libs/did-space.ts +0 -235
- package/api/src/libs/middleware.ts +0 -50
- package/api/src/libs/security.ts +0 -192
- package/api/src/queues/space.ts +0 -662
- package/api/src/routes/credit-tokens.ts +0 -38
- package/api/src/routes/exchange-rates.ts +0 -87
- package/api/src/routes/index.ts +0 -142
- package/api/src/routes/integrations/stripe.ts +0 -61
- package/api/src/routes/meters.ts +0 -274
- package/api/src/routes/passports.ts +0 -68
- package/api/src/routes/redirect.ts +0 -20
- package/api/src/routes/tool.ts +0 -65
- package/api/src/routes/webhook-endpoints.ts +0 -126
- package/api/tests/routes/credit-grants.spec.ts +0 -1261
- package/cloudflare/shims/did-space-js.ts +0 -17
- package/cloudflare/shims/did-space.ts +0 -11
- package/cloudflare/shims/express-compat/index.ts +0 -80
- package/cloudflare/shims/express-compat/types.ts +0 -41
- package/cloudflare/shims/lock.ts +0 -115
- package/cloudflare/shims/queue.ts +0 -611
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +0 -87
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +0 -186
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
// Phase 3 (express→hono) — hono fork of routes/subscriptions.ts
|
|
1
2
|
/* eslint-disable no-await-in-loop */
|
|
3
|
+
/* eslint-disable consistent-return */
|
|
2
4
|
import { isValid } from '@arcblock/did';
|
|
3
|
-
import { Router } from 'express';
|
|
4
5
|
import Joi from 'joi';
|
|
5
6
|
import isObject from 'lodash/isObject';
|
|
6
7
|
import pick from 'lodash/pick';
|
|
@@ -8,22 +9,30 @@ import uniq from 'lodash/uniq';
|
|
|
8
9
|
|
|
9
10
|
import { literal, Op, OrderItem } from 'sequelize';
|
|
10
11
|
import { BN, fromTokenToUnit } from '@ocap/util';
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import
|
|
15
|
-
import
|
|
16
|
-
|
|
17
|
-
|
|
12
|
+
import { Hono } from 'hono';
|
|
13
|
+
import { isProduction } from '../../libs/env';
|
|
14
|
+
import { createEvent, reportAuditFailure } from '../../libs/audit';
|
|
15
|
+
import { ensureStripeCustomer, ensureStripePrice, ensureStripeSubscription } from '../../integrations/stripe/resource';
|
|
16
|
+
import {
|
|
17
|
+
createListParamSchema,
|
|
18
|
+
getOrder,
|
|
19
|
+
getWhereFromKvQuery,
|
|
20
|
+
getWhereFromQuery,
|
|
21
|
+
MetadataSchema,
|
|
22
|
+
} from '../../libs/api';
|
|
23
|
+
import dayjs from '../../libs/dayjs';
|
|
24
|
+
import logger from '../../libs/logger';
|
|
25
|
+
import { isDelegationSufficientForPayment } from '../../libs/payment';
|
|
26
|
+
import { authenticate } from '../../middlewares/hono/security';
|
|
18
27
|
import {
|
|
19
28
|
expandLineItems,
|
|
20
29
|
getFastCheckoutAmount,
|
|
21
30
|
getSubscriptionCreateSetup,
|
|
22
31
|
isLineItemAligned,
|
|
23
32
|
SlippageOptions,
|
|
24
|
-
} from '
|
|
25
|
-
import { getExchangeRateService } from '
|
|
26
|
-
import { getExchangeRateSymbol } from '
|
|
33
|
+
} from '../../libs/session';
|
|
34
|
+
import { getExchangeRateService } from '../../libs/exchange-rate/service';
|
|
35
|
+
import { getExchangeRateSymbol } from '../../libs/exchange-rate/token-address-mapping';
|
|
27
36
|
import {
|
|
28
37
|
checkRemainingStake,
|
|
29
38
|
createProration,
|
|
@@ -36,10 +45,10 @@ import {
|
|
|
36
45
|
getSubscriptionUnpaidInvoicesCount,
|
|
37
46
|
getUpcomingInvoiceAmount,
|
|
38
47
|
isSubscriptionOverdraftProtectionEnabled,
|
|
39
|
-
} from '
|
|
40
|
-
import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '
|
|
41
|
-
import { trimDecimals, limitTokenPrecision } from '
|
|
42
|
-
import { invoiceQueue } from '
|
|
48
|
+
} from '../../libs/subscription';
|
|
49
|
+
import { MAX_SUBSCRIPTION_ITEM_COUNT, formatMetadata } from '../../libs/util';
|
|
50
|
+
import { trimDecimals, limitTokenPrecision } from '../../libs/math-utils';
|
|
51
|
+
import { invoiceQueue } from '../../queues/invoice';
|
|
43
52
|
import {
|
|
44
53
|
addSubscriptionJob,
|
|
45
54
|
returnOverdraftProtectionQueue,
|
|
@@ -47,38 +56,36 @@ import {
|
|
|
47
56
|
slashOverdraftProtectionQueue,
|
|
48
57
|
slashStakeQueue,
|
|
49
58
|
subscriptionQueue,
|
|
50
|
-
} from '
|
|
51
|
-
import type { TLineItemExpanded, ChainType } from '
|
|
52
|
-
import { Customer } from '
|
|
53
|
-
import { Invoice } from '
|
|
54
|
-
import { InvoiceItem } from '
|
|
55
|
-
import { Lock } from '
|
|
56
|
-
import { PaymentCurrency } from '
|
|
57
|
-
import { PaymentIntent } from '
|
|
58
|
-
import { PaymentMethod } from '
|
|
59
|
-
import { Price } from '
|
|
60
|
-
import { PriceQuote } from '
|
|
61
|
-
import { PricingTable } from '
|
|
62
|
-
import { Product } from '
|
|
63
|
-
import { SetupIntent } from '
|
|
64
|
-
import { Subscription, TSubscription } from '
|
|
65
|
-
import { SubscriptionItem } from '
|
|
66
|
-
import type { LineItem, ServiceAction, SubscriptionUpdateItem } from '
|
|
67
|
-
import { UsageRecord } from '
|
|
59
|
+
} from '../../queues/subscription';
|
|
60
|
+
import type { TLineItemExpanded, ChainType } from '../../store/models';
|
|
61
|
+
import { Customer } from '../../store/models/customer';
|
|
62
|
+
import { Invoice } from '../../store/models/invoice';
|
|
63
|
+
import { InvoiceItem } from '../../store/models/invoice-item';
|
|
64
|
+
import { Lock } from '../../store/models/lock';
|
|
65
|
+
import { PaymentCurrency } from '../../store/models/payment-currency';
|
|
66
|
+
import { PaymentIntent } from '../../store/models/payment-intent';
|
|
67
|
+
import { PaymentMethod } from '../../store/models/payment-method';
|
|
68
|
+
import { Price } from '../../store/models/price';
|
|
69
|
+
import { PriceQuote } from '../../store/models/price-quote';
|
|
70
|
+
import { PricingTable } from '../../store/models/pricing-table';
|
|
71
|
+
import { Product } from '../../store/models/product';
|
|
72
|
+
import { SetupIntent } from '../../store/models/setup-intent';
|
|
73
|
+
import { Subscription, TSubscription } from '../../store/models/subscription';
|
|
74
|
+
import { SubscriptionItem } from '../../store/models/subscription-item';
|
|
75
|
+
import type { LineItem, ServiceAction, SubscriptionUpdateItem } from '../../store/models/types';
|
|
76
|
+
import { UsageRecord } from '../../store/models/usage-record';
|
|
68
77
|
import {
|
|
69
78
|
cleanupInvoiceAndItems,
|
|
70
79
|
ensureInvoiceAndItems,
|
|
71
80
|
migrateSubscriptionPaymentMethodInvoice,
|
|
72
|
-
} from '
|
|
73
|
-
import {
|
|
74
|
-
import {
|
|
75
|
-
import {
|
|
76
|
-
import {
|
|
77
|
-
import {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
// S1 optimization: Load only the products/prices referenced by a set of subscriptions,
|
|
81
|
-
// instead of Product.findAll() + Price.findAll() full table scans.
|
|
81
|
+
} from '../../libs/invoice';
|
|
82
|
+
import { SubscriptionWillCanceledSchedule } from '../../crons/subscription-will-canceled';
|
|
83
|
+
import { getTokenByAddress } from '../../integrations/arcblock/stake';
|
|
84
|
+
import { ensureOverdraftProtectionPrice } from '../../libs/overdraft-protection';
|
|
85
|
+
import { CHARGE_SUPPORTED_CHAIN_TYPES } from '../../libs/constants';
|
|
86
|
+
import { getSubscriptionDiscountStats } from '../../libs/discount/redemption';
|
|
87
|
+
|
|
88
|
+
// S1 optimization: Load only the products/prices referenced by a set of subscriptions
|
|
82
89
|
async function loadProductsAndPricesForSubscriptions(docs: any[]): Promise<{ products: any[]; prices: any[] }> {
|
|
83
90
|
const priceIds = uniq(docs.flatMap((x) => (x.items || []).map((i: any) => i.price_id)).filter(Boolean));
|
|
84
91
|
if (priceIds.length === 0) {
|
|
@@ -89,7 +96,6 @@ async function loadProductsAndPricesForSubscriptions(docs: any[]): Promise<{ pro
|
|
|
89
96
|
include: [{ model: Product, as: 'product' }],
|
|
90
97
|
});
|
|
91
98
|
const pricesJson = prices.map((x) => x.toJSON());
|
|
92
|
-
// Derive products from the already-included price.product association
|
|
93
99
|
const productMap = new Map<string, any>();
|
|
94
100
|
pricesJson.forEach((p: any) => {
|
|
95
101
|
if (p.product) {
|
|
@@ -99,7 +105,7 @@ async function loadProductsAndPricesForSubscriptions(docs: any[]): Promise<{ pro
|
|
|
99
105
|
return { products: Array.from(productMap.values()), prices: pricesJson };
|
|
100
106
|
}
|
|
101
107
|
|
|
102
|
-
const
|
|
108
|
+
const app = new Hono();
|
|
103
109
|
const auth = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'] });
|
|
104
110
|
const authMine = authenticate<Subscription>({ component: true, roles: ['owner', 'admin'], mine: true });
|
|
105
111
|
const exchangeRateService = getExchangeRateService();
|
|
@@ -268,7 +274,6 @@ const createSchema = Joi.object({
|
|
|
268
274
|
.min(1)
|
|
269
275
|
.max(MAX_SUBSCRIPTION_ITEM_COUNT)
|
|
270
276
|
.required(),
|
|
271
|
-
// Optional: if not provided, subscription won't have automatic billing (for prepaid/gifted subscriptions)
|
|
272
277
|
default_payment_method_id: Joi.string().optional(),
|
|
273
278
|
currency_id: Joi.string().optional(),
|
|
274
279
|
trial_period_days: Joi.number().integer().min(0).optional(),
|
|
@@ -281,12 +286,13 @@ const createSchema = Joi.object({
|
|
|
281
286
|
collection_method: Joi.string().valid('charge_automatically', 'send_invoice').default('charge_automatically'),
|
|
282
287
|
proration_behavior: Joi.string().valid('always_invoice', 'create_prorations', 'none').default('none'),
|
|
283
288
|
service_actions: Joi.array().items(Joi.object()).optional(),
|
|
284
|
-
livemode: Joi.boolean().optional(),
|
|
289
|
+
livemode: Joi.boolean().optional(),
|
|
285
290
|
});
|
|
286
291
|
|
|
287
|
-
|
|
292
|
+
app.post('/', auth, async (c) => {
|
|
288
293
|
try {
|
|
289
|
-
const
|
|
294
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
295
|
+
const value = await createSchema.validateAsync(body);
|
|
290
296
|
const {
|
|
291
297
|
customer_id: customerId,
|
|
292
298
|
items,
|
|
@@ -304,35 +310,30 @@ router.post('/', auth, async (req, res) => {
|
|
|
304
310
|
livemode: livemodeInput,
|
|
305
311
|
} = value;
|
|
306
312
|
|
|
307
|
-
// Validate customer exists
|
|
308
313
|
const customer = await Customer.findByPk(customerId);
|
|
309
314
|
if (!customer) {
|
|
310
|
-
return
|
|
315
|
+
return c.json({ error: `Customer ${customerId} not found` }, 404);
|
|
311
316
|
}
|
|
312
317
|
|
|
313
|
-
// Validate payment method if provided
|
|
314
318
|
let paymentMethod: PaymentMethod | null = null;
|
|
315
319
|
if (paymentMethodId) {
|
|
316
320
|
paymentMethod = await PaymentMethod.findByPk(paymentMethodId);
|
|
317
321
|
if (!paymentMethod) {
|
|
318
|
-
return
|
|
322
|
+
return c.json({ error: `Payment method ${paymentMethodId} not found` }, 404);
|
|
319
323
|
}
|
|
320
324
|
}
|
|
321
325
|
|
|
322
|
-
// If no payment method, automatic billing is disabled (for prepaid/gifted subscriptions)
|
|
323
326
|
const hasAutomaticBilling = !!paymentMethod;
|
|
324
327
|
|
|
325
|
-
// Determine livemode
|
|
326
328
|
let livemode = livemodeInput;
|
|
327
329
|
if (livemode === undefined) {
|
|
328
330
|
if (paymentMethod) {
|
|
329
331
|
livemode = paymentMethod.livemode;
|
|
330
332
|
} else {
|
|
331
|
-
livemode = true;
|
|
333
|
+
livemode = true;
|
|
332
334
|
}
|
|
333
335
|
}
|
|
334
336
|
|
|
335
|
-
// Expand and validate line items
|
|
336
337
|
const priceIds = items.map((item: any) => item.price_id);
|
|
337
338
|
const prices = await Price.findAll({
|
|
338
339
|
where: { id: priceIds },
|
|
@@ -342,33 +343,33 @@ router.post('/', auth, async (req, res) => {
|
|
|
342
343
|
if (prices.length !== priceIds.length) {
|
|
343
344
|
const foundIds = prices.map((p) => p.id);
|
|
344
345
|
const missingIds = priceIds.filter((id: string) => !foundIds.includes(id));
|
|
345
|
-
return
|
|
346
|
+
return c.json({ error: `Prices not found: ${missingIds.join(', ')}` }, 404);
|
|
346
347
|
}
|
|
347
348
|
|
|
348
|
-
// Validate all items are recurring
|
|
349
349
|
const nonRecurringPrices = prices.filter((p) => p.type !== 'recurring');
|
|
350
350
|
if (nonRecurringPrices.length > 0) {
|
|
351
|
-
return
|
|
352
|
-
|
|
353
|
-
|
|
351
|
+
return c.json(
|
|
352
|
+
{
|
|
353
|
+
error: `Subscription only supports recurring prices. Non-recurring prices: ${nonRecurringPrices.map((p) => p.id).join(', ')}`,
|
|
354
|
+
},
|
|
355
|
+
400
|
|
356
|
+
);
|
|
354
357
|
}
|
|
355
358
|
|
|
356
|
-
// Determine currency from first price if not provided
|
|
357
359
|
let currencyId = value.currency_id;
|
|
358
360
|
const firstPrice = prices[0];
|
|
359
361
|
if (!currencyId && firstPrice) {
|
|
360
362
|
currencyId = firstPrice.currency_id;
|
|
361
363
|
}
|
|
362
364
|
if (!currencyId) {
|
|
363
|
-
return
|
|
365
|
+
return c.json({ error: 'currency_id is required' }, 400);
|
|
364
366
|
}
|
|
365
367
|
|
|
366
368
|
const paymentCurrency = await PaymentCurrency.findByPk(currencyId);
|
|
367
369
|
if (!paymentCurrency) {
|
|
368
|
-
return
|
|
370
|
+
return c.json({ error: `Payment currency ${currencyId} not found` }, 404);
|
|
369
371
|
}
|
|
370
372
|
|
|
371
|
-
// Build line items
|
|
372
373
|
const lineItems: TLineItemExpanded[] = items.map((item: any) => {
|
|
373
374
|
const price = prices.find((p) => p.id === item.price_id);
|
|
374
375
|
return {
|
|
@@ -379,15 +380,13 @@ router.post('/', auth, async (req, res) => {
|
|
|
379
380
|
};
|
|
380
381
|
});
|
|
381
382
|
|
|
382
|
-
// Calculate subscription setup (periods, trial, etc.)
|
|
383
383
|
const setup = getSubscriptionCreateSetup(lineItems, currencyId, trialPeriodDays, trialEndInput);
|
|
384
384
|
|
|
385
|
-
// Create subscription
|
|
386
385
|
const subscription = await Subscription.create({
|
|
387
386
|
livemode,
|
|
388
387
|
currency_id: currencyId,
|
|
389
388
|
customer_id: customerId,
|
|
390
|
-
status: 'active',
|
|
389
|
+
status: 'active',
|
|
391
390
|
current_period_start: setup.period.start,
|
|
392
391
|
current_period_end: setup.period.end,
|
|
393
392
|
billing_cycle_anchor: billingCycleAnchor || setup.cycle.anchor,
|
|
@@ -414,7 +413,6 @@ router.post('/', auth, async (req, res) => {
|
|
|
414
413
|
service_actions: serviceActions || [],
|
|
415
414
|
});
|
|
416
415
|
|
|
417
|
-
// Create subscription items
|
|
418
416
|
await Promise.all(
|
|
419
417
|
lineItems.map((item) =>
|
|
420
418
|
SubscriptionItem.create({
|
|
@@ -427,15 +425,13 @@ router.post('/', auth, async (req, res) => {
|
|
|
427
425
|
)
|
|
428
426
|
);
|
|
429
427
|
|
|
430
|
-
// If has trial period, set status to trialing
|
|
431
428
|
if (setup.trial.end && setup.trial.end > dayjs().unix()) {
|
|
432
429
|
await subscription.update({ status: 'trialing' });
|
|
433
|
-
createEvent('Subscription', 'customer.subscription.trial_start', subscription).catch(
|
|
430
|
+
createEvent('Subscription', 'customer.subscription.trial_start', subscription).catch(reportAuditFailure);
|
|
434
431
|
} else {
|
|
435
|
-
createEvent('Subscription', 'customer.subscription.started', subscription).catch(
|
|
432
|
+
createEvent('Subscription', 'customer.subscription.started', subscription).catch(reportAuditFailure);
|
|
436
433
|
}
|
|
437
434
|
|
|
438
|
-
// Schedule subscription cycle job only if has automatic billing
|
|
439
435
|
if (hasAutomaticBilling) {
|
|
440
436
|
await addSubscriptionJob(subscription, 'cycle', false, setup.trial.end || setup.period.end);
|
|
441
437
|
}
|
|
@@ -447,7 +443,6 @@ router.post('/', auth, async (req, res) => {
|
|
|
447
443
|
hasAutomaticBilling,
|
|
448
444
|
});
|
|
449
445
|
|
|
450
|
-
// Return expanded subscription
|
|
451
446
|
const result = await Subscription.findOne({
|
|
452
447
|
where: { id: subscription.id },
|
|
453
448
|
include: [
|
|
@@ -463,14 +458,14 @@ router.post('/', auth, async (req, res) => {
|
|
|
463
458
|
// @ts-ignore
|
|
464
459
|
expandLineItems(doc.items, products, allPrices);
|
|
465
460
|
|
|
466
|
-
return
|
|
461
|
+
return c.json(doc);
|
|
467
462
|
} catch (err: any) {
|
|
468
463
|
logger.error('Failed to create subscription', { error: err.message });
|
|
469
|
-
return
|
|
464
|
+
return c.json({ error: err.message }, 400);
|
|
470
465
|
}
|
|
471
466
|
});
|
|
472
467
|
|
|
473
|
-
|
|
468
|
+
app.get('/', authMine, async (c) => {
|
|
474
469
|
const {
|
|
475
470
|
page,
|
|
476
471
|
pageSize,
|
|
@@ -478,7 +473,7 @@ router.get('/', authMine, async (req, res) => {
|
|
|
478
473
|
livemode,
|
|
479
474
|
include_latest_invoice_quote: includeLatestInvoiceQuoteParam = false,
|
|
480
475
|
...query
|
|
481
|
-
} = await schema.validateAsync(req.query, {
|
|
476
|
+
} = await schema.validateAsync(c.req.query(), {
|
|
482
477
|
stripUnknown: false,
|
|
483
478
|
allowUnknown: true,
|
|
484
479
|
});
|
|
@@ -488,19 +483,18 @@ router.get('/', authMine, async (req, res) => {
|
|
|
488
483
|
if (status) {
|
|
489
484
|
where.status = status
|
|
490
485
|
.split(',')
|
|
491
|
-
.map((x) => x.trim())
|
|
486
|
+
.map((x: string) => x.trim())
|
|
492
487
|
.filter(Boolean);
|
|
493
488
|
}
|
|
494
|
-
if (query.customer_id) {
|
|
495
|
-
where.customer_id = query.customer_id;
|
|
489
|
+
if (c.get('customer_id') ?? query.customer_id) {
|
|
490
|
+
where.customer_id = c.get('customer_id') ?? query.customer_id;
|
|
496
491
|
}
|
|
497
492
|
if (query.customer_did && isValid(query.customer_did)) {
|
|
498
493
|
const customer = await Customer.findOne({ where: { did: query.customer_did } });
|
|
499
494
|
if (customer) {
|
|
500
495
|
where.customer_id = customer.id;
|
|
501
496
|
} else {
|
|
502
|
-
|
|
503
|
-
return;
|
|
497
|
+
return c.json({ count: 0, list: [], paging: { page, pageSize } });
|
|
504
498
|
}
|
|
505
499
|
}
|
|
506
500
|
where.livemode = typeof livemode === 'boolean' ? livemode : true;
|
|
@@ -512,7 +506,7 @@ router.get('/', authMine, async (req, res) => {
|
|
|
512
506
|
where[key] = query[key];
|
|
513
507
|
});
|
|
514
508
|
|
|
515
|
-
const order: OrderItem[] = getOrder(req.query, []);
|
|
509
|
+
const order: OrderItem[] = getOrder(c.req.query(), []);
|
|
516
510
|
|
|
517
511
|
if (query.activeFirst) {
|
|
518
512
|
order.unshift([
|
|
@@ -541,7 +535,6 @@ router.get('/', authMine, async (req, res) => {
|
|
|
541
535
|
{ model: SubscriptionItem, as: 'items' },
|
|
542
536
|
{ model: Customer, as: 'customer' },
|
|
543
537
|
],
|
|
544
|
-
// https://github.com/sequelize/sequelize/issues/9481
|
|
545
538
|
distinct: true,
|
|
546
539
|
});
|
|
547
540
|
const docs = list.map((x) => x.toJSON());
|
|
@@ -560,13 +553,12 @@ router.get('/', authMine, async (req, res) => {
|
|
|
560
553
|
},
|
|
561
554
|
distinct: true,
|
|
562
555
|
});
|
|
563
|
-
|
|
564
|
-
} else {
|
|
565
|
-
res.json({ count, list: docs, paging: { page, pageSize } });
|
|
556
|
+
return c.json({ count, list: docs, paging: { page, pageSize }, totalCount });
|
|
566
557
|
}
|
|
558
|
+
return c.json({ count, list: docs, paging: { page, pageSize } });
|
|
567
559
|
} catch (err) {
|
|
568
560
|
logger.error(err);
|
|
569
|
-
|
|
561
|
+
return c.json({ count: 0, list: [], paging: { page, pageSize } });
|
|
570
562
|
}
|
|
571
563
|
});
|
|
572
564
|
|
|
@@ -578,7 +570,8 @@ const searchSchema = createListParamSchema<{
|
|
|
578
570
|
query: Joi.string(),
|
|
579
571
|
include_latest_invoice_quote: Joi.boolean().optional(),
|
|
580
572
|
});
|
|
581
|
-
|
|
573
|
+
|
|
574
|
+
app.get('/search', auth, async (c) => {
|
|
582
575
|
const {
|
|
583
576
|
page,
|
|
584
577
|
pageSize,
|
|
@@ -587,7 +580,7 @@ router.get('/search', auth, async (req, res) => {
|
|
|
587
580
|
q,
|
|
588
581
|
o,
|
|
589
582
|
include_latest_invoice_quote: includeLatestInvoiceQuoteParam = false,
|
|
590
|
-
} = await searchSchema.validateAsync(req.query, {
|
|
583
|
+
} = await searchSchema.validateAsync(c.req.query(), {
|
|
591
584
|
stripUnknown: false,
|
|
592
585
|
allowUnknown: true,
|
|
593
586
|
});
|
|
@@ -597,10 +590,9 @@ router.get('/search', auth, async (req, res) => {
|
|
|
597
590
|
if (typeof livemode === 'boolean') {
|
|
598
591
|
where.livemode = livemode;
|
|
599
592
|
}
|
|
600
|
-
// fix here https://github.com/blocklet/payment-kit/issues/394
|
|
601
593
|
const { rows: list, count } = await Subscription.findAndCountAll({
|
|
602
594
|
where,
|
|
603
|
-
order: getOrder(req.query, [['created_at', o === 'asc' ? 'ASC' : 'DESC']]),
|
|
595
|
+
order: getOrder(c.req.query(), [['created_at', o === 'asc' ? 'ASC' : 'DESC']]),
|
|
604
596
|
offset: (page - 1) * pageSize,
|
|
605
597
|
limit: pageSize,
|
|
606
598
|
distinct: true,
|
|
@@ -619,13 +611,13 @@ router.get('/search', auth, async (req, res) => {
|
|
|
619
611
|
if (includeLatestInvoiceQuote) {
|
|
620
612
|
await attachLatestInvoiceQuotes(docs);
|
|
621
613
|
}
|
|
622
|
-
|
|
614
|
+
return c.json({ count, list: docs, paging: { page, pageSize } });
|
|
623
615
|
});
|
|
624
616
|
|
|
625
|
-
|
|
617
|
+
app.get('/:id', authPortal, async (c) => {
|
|
626
618
|
try {
|
|
627
619
|
const doc = (await Subscription.findOne({
|
|
628
|
-
where: { id: req.
|
|
620
|
+
where: { id: c.req.param('id') },
|
|
629
621
|
include: [
|
|
630
622
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
631
623
|
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
@@ -649,7 +641,6 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
649
641
|
// @ts-ignore
|
|
650
642
|
json.serviceType = serviceType;
|
|
651
643
|
|
|
652
|
-
// Get discount statistics if subscription has active discounts
|
|
653
644
|
let discountStats = null;
|
|
654
645
|
try {
|
|
655
646
|
const stats = await getSubscriptionDiscountStats(json.id);
|
|
@@ -660,7 +651,6 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
660
651
|
logger.error('Failed to fetch subscription discount stats', { error, subscriptionId: json.id });
|
|
661
652
|
}
|
|
662
653
|
|
|
663
|
-
// Get payment method details
|
|
664
654
|
let paymentMethodDetails = null;
|
|
665
655
|
try {
|
|
666
656
|
const paymentMethod = (doc as any).paymentMethod as PaymentMethod | null;
|
|
@@ -720,47 +710,48 @@ router.get('/:id', authPortal, async (req, res) => {
|
|
|
720
710
|
logger.error('Failed to fetch payment method details', { error, subscriptionId: json.id });
|
|
721
711
|
}
|
|
722
712
|
|
|
723
|
-
|
|
713
|
+
return c.json({
|
|
724
714
|
...json,
|
|
725
715
|
discountStats,
|
|
726
716
|
paymentMethodDetails,
|
|
727
717
|
});
|
|
728
|
-
} else {
|
|
729
|
-
res.status(404).json(null);
|
|
730
718
|
}
|
|
731
|
-
|
|
719
|
+
return c.json(null, 404);
|
|
720
|
+
} catch (err: any) {
|
|
732
721
|
logger.error(err);
|
|
733
|
-
|
|
722
|
+
return c.json({ error: `Failed to get subscription: ${err.message}` }, 500);
|
|
734
723
|
}
|
|
735
724
|
});
|
|
736
725
|
|
|
737
726
|
const CommentSchema = Joi.string().max(200).empty('').optional();
|
|
738
727
|
const SlashStakeSchema = Joi.string().max(200).required();
|
|
739
728
|
|
|
740
|
-
|
|
741
|
-
const
|
|
729
|
+
app.put('/:id/cancel', authPortal, async (c) => {
|
|
730
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
731
|
+
const { error: commentError } = CommentSchema.validate(body?.comment);
|
|
742
732
|
if (commentError) {
|
|
743
|
-
return
|
|
733
|
+
return c.json({ error: `comment invalid: ${commentError.message}` }, 400);
|
|
744
734
|
}
|
|
745
735
|
|
|
746
|
-
const
|
|
747
|
-
const
|
|
736
|
+
const user = c.get('user');
|
|
737
|
+
const requestByAdmin = ['owner', 'admin'].includes(user?.role as string);
|
|
738
|
+
const slashStake = requestByAdmin && body?.staking === 'slash';
|
|
748
739
|
|
|
749
740
|
if (slashStake) {
|
|
750
|
-
const { error: slashReasonError } = SlashStakeSchema.validate(
|
|
741
|
+
const { error: slashReasonError } = SlashStakeSchema.validate(body?.slashReason);
|
|
751
742
|
if (slashReasonError) {
|
|
752
|
-
return
|
|
743
|
+
return c.json({ error: `slash reason invalid: ${slashReasonError.message}` }, 400);
|
|
753
744
|
}
|
|
754
745
|
}
|
|
755
746
|
|
|
756
|
-
const subscription = await Subscription.findByPk(req.
|
|
757
|
-
logger.info('subscription cancel request', {
|
|
747
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
748
|
+
logger.info('subscription cancel request', { id: c.req.param('id'), ...body });
|
|
758
749
|
|
|
759
750
|
if (!subscription) {
|
|
760
|
-
return
|
|
751
|
+
return c.json({ error: 'subscription not found' }, 404);
|
|
761
752
|
}
|
|
762
753
|
if (subscription.status === 'canceled') {
|
|
763
|
-
return
|
|
754
|
+
return c.json({ error: 'Subscription already canceled' }, 400);
|
|
764
755
|
}
|
|
765
756
|
|
|
766
757
|
const {
|
|
@@ -772,20 +763,19 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
772
763
|
reason = 'payment_disputed',
|
|
773
764
|
staking = 'none',
|
|
774
765
|
slashReason = 'admin slash',
|
|
775
|
-
} =
|
|
766
|
+
} = body;
|
|
776
767
|
if (at === 'custom' && dayjs(time).unix() < dayjs().unix()) {
|
|
777
|
-
return
|
|
768
|
+
return c.json({ error: 'cancel at must be a future timestamp' }, 400);
|
|
778
769
|
}
|
|
779
770
|
|
|
780
771
|
let canReturnStake = false;
|
|
781
|
-
if ((requestByAdmin && staking === 'proration') ||
|
|
772
|
+
if ((requestByAdmin && staking === 'proration') || body?.cancel_from === 'customer') {
|
|
782
773
|
canReturnStake = true;
|
|
783
774
|
}
|
|
784
775
|
const haveStake = !!subscription.payment_details?.arcblock?.staking?.tx_hash;
|
|
785
|
-
// update cancel at
|
|
786
776
|
const updates: Partial<Subscription> = {
|
|
787
777
|
cancelation_details: {
|
|
788
|
-
comment: comment || `Requested by ${
|
|
778
|
+
comment: comment || `Requested by ${user?.role}:${user?.did}`,
|
|
789
779
|
reason: reason || 'payment_disputed',
|
|
790
780
|
feedback: feedback || 'other',
|
|
791
781
|
return_stake: canReturnStake && haveStake,
|
|
@@ -794,7 +784,7 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
794
784
|
},
|
|
795
785
|
};
|
|
796
786
|
const now = dayjs().unix() + 3;
|
|
797
|
-
if (
|
|
787
|
+
if (user?.via === 'portal' || body?.cancel_from === 'customer') {
|
|
798
788
|
const inTrialing = subscription.status === 'trialing';
|
|
799
789
|
updates.cancel_at_period_end = true;
|
|
800
790
|
updates.cancel_at = subscription.current_period_end;
|
|
@@ -813,8 +803,8 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
813
803
|
}
|
|
814
804
|
await addSubscriptionJob(subscription, 'cancel', true, updates.cancel_at);
|
|
815
805
|
} else {
|
|
816
|
-
if (['owner', 'admin'].includes(
|
|
817
|
-
return
|
|
806
|
+
if (['owner', 'admin'].includes(user?.role as string) === false) {
|
|
807
|
+
return c.json({ error: 'Not authorized to perform this action' }, 403);
|
|
818
808
|
}
|
|
819
809
|
|
|
820
810
|
if (at === 'now') {
|
|
@@ -842,7 +832,11 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
842
832
|
if (subscription.payment_details?.stripe?.subscription_id) {
|
|
843
833
|
const method = await PaymentMethod.findOne({ where: { type: 'stripe', livemode: subscription.livemode } });
|
|
844
834
|
if (method) {
|
|
845
|
-
logger.info('subscription cancel attempt on stripe', {
|
|
835
|
+
logger.info('subscription cancel attempt on stripe', {
|
|
836
|
+
subscription: c.req.param('id'),
|
|
837
|
+
method: method.id,
|
|
838
|
+
updates,
|
|
839
|
+
});
|
|
846
840
|
const client = method.getStripeClient();
|
|
847
841
|
try {
|
|
848
842
|
if (updates.cancel_at_period_end) {
|
|
@@ -855,18 +849,20 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
855
849
|
proration_behavior: 'none',
|
|
856
850
|
});
|
|
857
851
|
}
|
|
858
|
-
logger.info('subscription cancel done on stripe', {
|
|
852
|
+
logger.info('subscription cancel done on stripe', {
|
|
853
|
+
subscription: c.req.param('id'),
|
|
854
|
+
method: method.id,
|
|
855
|
+
updates,
|
|
856
|
+
});
|
|
859
857
|
} catch (err) {
|
|
860
|
-
|
|
861
|
-
logger.error('subscription cancel failed on stripe', { subscription: req.params.id, updates, error: err });
|
|
858
|
+
logger.error('subscription cancel failed on stripe', { subscription: c.req.param('id'), updates, error: err });
|
|
862
859
|
}
|
|
863
860
|
}
|
|
864
861
|
}
|
|
865
862
|
|
|
866
|
-
// trigger refund
|
|
867
863
|
if (updates.cancel_at < subscription.current_period_end && refund !== 'none') {
|
|
868
|
-
if (['owner', 'admin'].includes(
|
|
869
|
-
return
|
|
864
|
+
if (['owner', 'admin'].includes(user?.role as string) === false) {
|
|
865
|
+
return c.json({ error: 'Not authorized to refund' }, 403);
|
|
870
866
|
}
|
|
871
867
|
const result = await getSubscriptionRefundSetup(subscription, updates.cancel_at);
|
|
872
868
|
if (result.remainingUnused !== '0') {
|
|
@@ -874,18 +870,18 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
874
870
|
updates.cancelation_details = {
|
|
875
871
|
...(updates.cancelation_details || {}),
|
|
876
872
|
refund,
|
|
877
|
-
requested_by:
|
|
873
|
+
requested_by: user?.did,
|
|
878
874
|
};
|
|
879
875
|
logger.info('subscription cancel with refund', {
|
|
880
|
-
|
|
881
|
-
...
|
|
876
|
+
id: c.req.param('id'),
|
|
877
|
+
...body,
|
|
882
878
|
refund,
|
|
883
879
|
...pick(result, ['total', 'unused']),
|
|
884
880
|
});
|
|
885
881
|
} else {
|
|
886
882
|
logger.info('subscription cancel no refund', {
|
|
887
|
-
|
|
888
|
-
...
|
|
883
|
+
id: c.req.param('id'),
|
|
884
|
+
...body,
|
|
889
885
|
...pick(result, ['total', 'unused']),
|
|
890
886
|
});
|
|
891
887
|
}
|
|
@@ -895,19 +891,19 @@ router.put('/:id/cancel', authPortal, async (req, res) => {
|
|
|
895
891
|
logger.info('Update subscription for cancel request successful', {
|
|
896
892
|
subscriptionId: subscription.id,
|
|
897
893
|
customerId: subscription.customer_id,
|
|
898
|
-
reason:
|
|
894
|
+
reason: body.reason,
|
|
899
895
|
cancelAt: subscription.cancel_at,
|
|
900
|
-
requestedBy:
|
|
896
|
+
requestedBy: user?.did,
|
|
901
897
|
updates,
|
|
902
898
|
});
|
|
903
|
-
return
|
|
899
|
+
return c.json(subscription);
|
|
904
900
|
});
|
|
905
901
|
|
|
906
|
-
|
|
907
|
-
const doc = await Subscription.findByPk(req.
|
|
902
|
+
app.get('/:id/recover-info', authPortal, async (c) => {
|
|
903
|
+
const doc = await Subscription.findByPk(c.req.param('id'));
|
|
908
904
|
|
|
909
905
|
if (!doc) {
|
|
910
|
-
return
|
|
906
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
911
907
|
}
|
|
912
908
|
|
|
913
909
|
const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
|
|
@@ -932,39 +928,38 @@ router.get('/:id/recover-info', authPortal, async (req, res) => {
|
|
|
932
928
|
}
|
|
933
929
|
}
|
|
934
930
|
|
|
935
|
-
return
|
|
931
|
+
return c.json({
|
|
936
932
|
subscription: doc,
|
|
937
933
|
needStake,
|
|
938
934
|
revokedStake,
|
|
939
935
|
});
|
|
940
936
|
});
|
|
941
937
|
|
|
942
|
-
|
|
943
|
-
const doc = await Subscription.findByPk(req.
|
|
938
|
+
app.put('/:id/recover', authPortal, async (c) => {
|
|
939
|
+
const doc = await Subscription.findByPk(c.req.param('id'));
|
|
944
940
|
|
|
945
941
|
if (!doc) {
|
|
946
|
-
return
|
|
942
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
947
943
|
}
|
|
948
944
|
if (!doc.cancel_at_period_end) {
|
|
949
|
-
return
|
|
945
|
+
return c.json({ error: 'Subscription not recoverable from cancellation config' }, 400);
|
|
950
946
|
}
|
|
951
947
|
if (doc.cancelation_details?.reason === 'payment_failed') {
|
|
952
|
-
return
|
|
948
|
+
return c.json({ error: 'Subscription not recoverable from payment failed' }, 400);
|
|
953
949
|
}
|
|
954
950
|
if (doc.status === 'canceled') {
|
|
955
|
-
return
|
|
951
|
+
return c.json({ error: 'Subscription not recoverable from cancellation' }, 400);
|
|
956
952
|
}
|
|
957
953
|
|
|
958
954
|
const paymentMethod = await PaymentMethod.findByPk(doc.default_payment_method_id);
|
|
959
955
|
if (!paymentMethod) {
|
|
960
|
-
return
|
|
956
|
+
return c.json({ error: 'Payment method not found' }, 400);
|
|
961
957
|
}
|
|
962
958
|
const paymentCurrency = await PaymentCurrency.findByPk(doc.currency_id);
|
|
963
959
|
if (!paymentCurrency) {
|
|
964
|
-
return
|
|
960
|
+
return c.json({ error: 'Payment currency not found' }, 400);
|
|
965
961
|
}
|
|
966
962
|
|
|
967
|
-
// check if need stake
|
|
968
963
|
if (paymentMethod.type === 'arcblock') {
|
|
969
964
|
const address = doc.payment_details?.arcblock?.staking?.address;
|
|
970
965
|
if (address) {
|
|
@@ -972,7 +967,7 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
972
967
|
const { revoked } = await checkRemainingStake(paymentMethod, paymentCurrency, address, '0');
|
|
973
968
|
const cancelReason = doc.cancelation_details?.reason;
|
|
974
969
|
if (revoked && revoked.value !== '0' && cancelReason === 'stake_revoked') {
|
|
975
|
-
return
|
|
970
|
+
return c.json({
|
|
976
971
|
needStake: true,
|
|
977
972
|
subscription: doc,
|
|
978
973
|
revoked,
|
|
@@ -999,29 +994,29 @@ router.put('/:id/recover', authPortal, async (req, res) => {
|
|
|
999
994
|
canceled_at: 0,
|
|
1000
995
|
});
|
|
1001
996
|
await new SubscriptionWillCanceledSchedule().deleteScheduleSubscriptionJobs([doc]);
|
|
1002
|
-
// reschedule jobs
|
|
1003
997
|
subscriptionQueue
|
|
1004
998
|
.delete(`cancel-${doc.id}`)
|
|
1005
999
|
.then(() => logger.info('subscription cancel job is canceled'))
|
|
1006
1000
|
.catch((err) => logger.error('subscription cancel job failed to cancel', { error: err }));
|
|
1007
1001
|
await addSubscriptionJob(doc, 'cycle');
|
|
1008
1002
|
|
|
1009
|
-
return
|
|
1003
|
+
return c.json({ subscription: doc });
|
|
1010
1004
|
});
|
|
1011
1005
|
|
|
1012
|
-
|
|
1013
|
-
const doc = await Subscription.findByPk(req.
|
|
1006
|
+
app.put('/:id/pause', auth, async (c) => {
|
|
1007
|
+
const doc = await Subscription.findByPk(c.req.param('id'));
|
|
1014
1008
|
|
|
1015
1009
|
if (!doc) {
|
|
1016
|
-
return
|
|
1010
|
+
return c.json({ error: 'subscription not found' }, 404);
|
|
1017
1011
|
}
|
|
1018
1012
|
if (doc.status === 'paused') {
|
|
1019
|
-
return
|
|
1013
|
+
return c.json({ error: 'Subscription already paused' }, 400);
|
|
1020
1014
|
}
|
|
1021
1015
|
|
|
1022
|
-
const
|
|
1016
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
1017
|
+
const { type, resumesAt, behavior } = body;
|
|
1023
1018
|
if (type === 'custom' && dayjs(resumesAt).unix() < dayjs().unix()) {
|
|
1024
|
-
return
|
|
1019
|
+
return c.json({ error: 'resumesAt must be a future timestamp' }, 400);
|
|
1025
1020
|
}
|
|
1026
1021
|
|
|
1027
1022
|
const timestamp = type === 'custom' ? dayjs(resumesAt).unix() : 0;
|
|
@@ -1045,17 +1040,17 @@ router.put('/:id/pause', auth, async (req, res) => {
|
|
|
1045
1040
|
await addSubscriptionJob(doc, 'resume', false, timestamp);
|
|
1046
1041
|
}
|
|
1047
1042
|
|
|
1048
|
-
return
|
|
1043
|
+
return c.json(doc);
|
|
1049
1044
|
});
|
|
1050
1045
|
|
|
1051
|
-
|
|
1052
|
-
const doc = await Subscription.findByPk(req.
|
|
1046
|
+
app.put('/:id/resume', auth, async (c) => {
|
|
1047
|
+
const doc = await Subscription.findByPk(c.req.param('id'));
|
|
1053
1048
|
|
|
1054
1049
|
if (!doc) {
|
|
1055
|
-
return
|
|
1050
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1056
1051
|
}
|
|
1057
1052
|
if (doc.status !== 'paused' && doc.pause_collection === null) {
|
|
1058
|
-
return
|
|
1053
|
+
return c.json({ error: 'Subscription not paused' }, 400);
|
|
1059
1054
|
}
|
|
1060
1055
|
|
|
1061
1056
|
await updateStripeSubscription(doc, { pause_collection: null });
|
|
@@ -1066,27 +1061,27 @@ router.put('/:id/resume', auth, async (req, res) => {
|
|
|
1066
1061
|
.then(() => logger.info('subscription resume job is canceled'))
|
|
1067
1062
|
.catch((err) => logger.error('subscription resume job failed to cancel', { error: err }));
|
|
1068
1063
|
|
|
1069
|
-
return
|
|
1064
|
+
return c.json(doc);
|
|
1070
1065
|
});
|
|
1071
1066
|
|
|
1072
|
-
|
|
1073
|
-
const doc = await Subscription.findByPk(req.
|
|
1067
|
+
app.put('/:id/return-stake', authPortal, async (c) => {
|
|
1068
|
+
const doc = await Subscription.findByPk(c.req.param('id'));
|
|
1074
1069
|
if (!doc) {
|
|
1075
|
-
return
|
|
1070
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1076
1071
|
}
|
|
1077
1072
|
if (doc.status !== 'canceled') {
|
|
1078
|
-
return
|
|
1073
|
+
return c.json({ error: 'Subscription is not canceled' }, 400);
|
|
1079
1074
|
}
|
|
1080
1075
|
|
|
1081
1076
|
if (!doc.payment_details?.arcblock?.staking?.tx_hash) {
|
|
1082
|
-
return
|
|
1077
|
+
return c.json({ error: 'No staking transaction found in subscription' }, 400);
|
|
1083
1078
|
}
|
|
1084
1079
|
returnStakeQueue.push({ id: `return-stake-${doc.id}`, job: { subscriptionId: doc.id } });
|
|
1085
1080
|
logger.info('Subscription return stake job scheduled', {
|
|
1086
1081
|
jobId: `return-stake-${doc.id}`,
|
|
1087
1082
|
subscription: doc.id,
|
|
1088
1083
|
});
|
|
1089
|
-
return
|
|
1084
|
+
return c.json({ success: true, subscriptionId: doc.id });
|
|
1090
1085
|
});
|
|
1091
1086
|
|
|
1092
1087
|
const isValidSubscriptionItemChange = (item: SubscriptionUpdateItem) => {
|
|
@@ -1114,28 +1109,23 @@ const isValidSubscriptionItemChange = (item: SubscriptionUpdateItem) => {
|
|
|
1114
1109
|
};
|
|
1115
1110
|
|
|
1116
1111
|
const validateSubscriptionUpdateRequest = async (subscription: Subscription, items: SubscriptionUpdateItem[]) => {
|
|
1117
|
-
// validate
|
|
1118
1112
|
items.every(isValidSubscriptionItemChange);
|
|
1119
1113
|
|
|
1120
|
-
// ensure no duplicate id
|
|
1121
1114
|
const ids = items.filter((x: any) => x.id).map((x: any) => x.id);
|
|
1122
1115
|
if (uniq(ids).length !== ids.length) {
|
|
1123
1116
|
throw new Error('Subscription item should not have duplicate id');
|
|
1124
1117
|
}
|
|
1125
1118
|
|
|
1126
|
-
// ensure no duplicate price_id
|
|
1127
1119
|
const priceIds = items.filter((x: any) => x.price_id).map((x: any) => x.price_id);
|
|
1128
1120
|
if (uniq(priceIds).length !== priceIds.length) {
|
|
1129
1121
|
throw new Error('Subscription item should not have duplicate price_id');
|
|
1130
1122
|
}
|
|
1131
1123
|
|
|
1132
|
-
// split items into added, deleted
|
|
1133
1124
|
const existingItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
1134
1125
|
const addedItems = items.filter((x: any) => x.price_id && !x.id);
|
|
1135
1126
|
const deletedItems = items.filter((x: any) => x.deleted && x.id);
|
|
1136
1127
|
const updatedItems = items.filter((x: any) => !x.deleted && x.id && existingItems.some((i) => i.id === x.id));
|
|
1137
1128
|
|
|
1138
|
-
// try handle cross-sell with different interval, just replace with new price that have same interval
|
|
1139
1129
|
let addedExpanded = await Price.expand(addedItems as LineItem[]);
|
|
1140
1130
|
let existingExpanded = await Price.expand(
|
|
1141
1131
|
existingItems.filter((x) => deletedItems.some((y) => y.id === x.id) === false).map((x) => x.toJSON())
|
|
@@ -1225,7 +1215,6 @@ const validateSubscriptionUpdateRequest = async (subscription: Subscription, ite
|
|
|
1225
1215
|
};
|
|
1226
1216
|
};
|
|
1227
1217
|
|
|
1228
|
-
// TODO: @wangshijun forward changes to stripe
|
|
1229
1218
|
const updateSchema = Joi.object<{
|
|
1230
1219
|
description?: string;
|
|
1231
1220
|
metadata?: Record<string, any>;
|
|
@@ -1269,24 +1258,23 @@ const updateSchema = Joi.object<{
|
|
|
1269
1258
|
)
|
|
1270
1259
|
.optional(),
|
|
1271
1260
|
});
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1261
|
+
|
|
1262
|
+
app.put('/:id', authPortal, async (c) => {
|
|
1263
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
1264
|
+
logger.debug('subscription update request', { subscription: c.req.param('id'), body });
|
|
1275
1265
|
try {
|
|
1276
|
-
const { error, value } = updateSchema.validate(
|
|
1266
|
+
const { error, value } = updateSchema.validate(body);
|
|
1277
1267
|
if (error) {
|
|
1278
|
-
return
|
|
1268
|
+
return c.json({ error: `Subscription update request invalid: ${error.message}` }, 400);
|
|
1279
1269
|
}
|
|
1280
1270
|
|
|
1281
|
-
const subscription = await Subscription.findByPk(req.
|
|
1271
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
1282
1272
|
if (!subscription) {
|
|
1283
|
-
return
|
|
1273
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1284
1274
|
}
|
|
1285
1275
|
|
|
1286
|
-
// handle updates
|
|
1287
1276
|
const updates: Partial<TSubscription> = {};
|
|
1288
1277
|
|
|
1289
|
-
// only metadata + description can be updated when not active
|
|
1290
1278
|
if (value.metadata) {
|
|
1291
1279
|
updates.metadata = formatMetadata(value.metadata);
|
|
1292
1280
|
}
|
|
@@ -1298,10 +1286,9 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1298
1286
|
}
|
|
1299
1287
|
if (subscription.isActive() === false) {
|
|
1300
1288
|
await subscription.update(updates);
|
|
1301
|
-
return
|
|
1289
|
+
return c.json(subscription);
|
|
1302
1290
|
}
|
|
1303
1291
|
|
|
1304
|
-
// other updates are allowed
|
|
1305
1292
|
if (value.payment_behavior) {
|
|
1306
1293
|
updates.payment_behavior = value.payment_behavior;
|
|
1307
1294
|
}
|
|
@@ -1323,7 +1310,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1323
1310
|
throw new Error('Subscription should have customer');
|
|
1324
1311
|
}
|
|
1325
1312
|
|
|
1326
|
-
// handle subscription item changes
|
|
1327
1313
|
let connectAction = '';
|
|
1328
1314
|
if (Array.isArray(value.items) && value.items.length > 0) {
|
|
1329
1315
|
const prorationBehavior = updates.proration_behavior || subscription.proration_behavior || 'none';
|
|
@@ -1340,7 +1326,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1340
1326
|
throw new Error('Updating subscription item not allowed now until next billing cycle');
|
|
1341
1327
|
}
|
|
1342
1328
|
|
|
1343
|
-
// validate the request
|
|
1344
1329
|
const { addedItems, updatedItems, deletedItems, newItems } = await validateSubscriptionUpdateRequest(
|
|
1345
1330
|
subscription,
|
|
1346
1331
|
value.items
|
|
@@ -1380,7 +1365,7 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1380
1365
|
|
|
1381
1366
|
const stripeItems = [...addedStripeItems, ...updatedStripeItems, ...deletedStripeItems].filter(Boolean);
|
|
1382
1367
|
logger.info('stripe subscription update attempt', {
|
|
1383
|
-
subscription: req.
|
|
1368
|
+
subscription: c.req.param('id'),
|
|
1384
1369
|
stripeSubscriptionId,
|
|
1385
1370
|
addedStripeItems,
|
|
1386
1371
|
updatedStripeItems,
|
|
@@ -1389,7 +1374,7 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1389
1374
|
|
|
1390
1375
|
await subscription.update({
|
|
1391
1376
|
pending_update: {
|
|
1392
|
-
expires_at: dayjs().unix() + 30 * 60,
|
|
1377
|
+
expires_at: dayjs().unix() + 30 * 60,
|
|
1393
1378
|
updates,
|
|
1394
1379
|
addedItems,
|
|
1395
1380
|
deletedItems,
|
|
@@ -1410,25 +1395,17 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1410
1395
|
items: stripeItems,
|
|
1411
1396
|
});
|
|
1412
1397
|
logger.info('stripe subscription update done', {
|
|
1413
|
-
subscription: req.
|
|
1398
|
+
subscription: c.req.param('id'),
|
|
1414
1399
|
stripeSubscriptionId,
|
|
1415
1400
|
prorationBehavior,
|
|
1416
1401
|
result,
|
|
1417
1402
|
});
|
|
1418
1403
|
} else {
|
|
1419
|
-
// update subscription period settings
|
|
1420
|
-
// HINT: if we are adding new items, we need to reset the anchor to now
|
|
1421
|
-
// For change-plan (proration), we need exact amounts, not authorization amounts with slippage buffer.
|
|
1422
|
-
// So we don't pass minAcceptableRate or slippage percent here.
|
|
1423
|
-
// The custom_amount we set below will be used as-is.
|
|
1424
1404
|
const slippageOptions: SlippageOptions = {
|
|
1425
|
-
percent: 0,
|
|
1426
|
-
// Don't include minAcceptableRate - it would override custom_amount calculation
|
|
1405
|
+
percent: 0,
|
|
1427
1406
|
currencyDecimal: paymentCurrency.decimal,
|
|
1428
1407
|
};
|
|
1429
1408
|
|
|
1430
|
-
// For dynamic pricing items, calculate custom_amount using current exchange rate
|
|
1431
|
-
// This ensures the total is calculated with current rate, not the stale unit_amount
|
|
1432
1409
|
const hasDynamicPricing = newItems.some((x) => (x.upsell_price || x.price).pricing_type === 'dynamic');
|
|
1433
1410
|
if (hasDynamicPricing) {
|
|
1434
1411
|
const currencyPaymentMethod =
|
|
@@ -1440,20 +1417,16 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1440
1417
|
if (rateResult?.rate) {
|
|
1441
1418
|
const USD_DECIMALS = 8;
|
|
1442
1419
|
const currentRate = rateResult.rate;
|
|
1443
|
-
// Set custom_amount for each dynamic pricing item
|
|
1444
1420
|
newItems.forEach((item: any) => {
|
|
1445
1421
|
const price = item.upsell_price || item.price;
|
|
1446
1422
|
if (price.pricing_type === 'dynamic' && price.base_amount) {
|
|
1447
|
-
// Calculate: base_amount / rate * 10^decimal
|
|
1448
|
-
// Use trimDecimals to avoid "too many decimal places" error
|
|
1449
1423
|
const baseAmountBN = fromTokenToUnit(trimDecimals(price.base_amount, USD_DECIMALS), USD_DECIMALS);
|
|
1450
1424
|
const rateBN = fromTokenToUnit(trimDecimals(currentRate, USD_DECIMALS), USD_DECIMALS);
|
|
1451
1425
|
if (rateBN.gt(new BN(0))) {
|
|
1452
1426
|
const amountBN = baseAmountBN
|
|
1453
1427
|
.mul(new BN(10).pow(new BN(paymentCurrency.decimal)))
|
|
1454
|
-
.add(rateBN.sub(new BN(1)))
|
|
1428
|
+
.add(rateBN.sub(new BN(1)))
|
|
1455
1429
|
.div(rateBN);
|
|
1456
|
-
// Apply same precision limiting as quote service (10 significant decimal places)
|
|
1457
1430
|
const totalAmountBN = amountBN.mul(new BN(item.quantity));
|
|
1458
1431
|
item.custom_amount = limitTokenPrecision(totalAmountBN, paymentCurrency.decimal, 10).toString();
|
|
1459
1432
|
logger.info('Set custom_amount for dynamic pricing item in subscription update', {
|
|
@@ -1476,7 +1449,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1476
1449
|
}
|
|
1477
1450
|
|
|
1478
1451
|
const setup = getSubscriptionCreateSetup(newItems, paymentCurrency.id, 0, 0, slippageOptions);
|
|
1479
|
-
// Check if the subscription is currently in trial
|
|
1480
1452
|
const isInTrial =
|
|
1481
1453
|
subscription.status === 'trialing' && subscription.trial_end && subscription.trial_end > dayjs().unix();
|
|
1482
1454
|
if (newItems.some((x) => x.price.type === 'recurring' && addedItems.find((y) => y.price_id === x.price_id))) {
|
|
@@ -1486,19 +1458,16 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1486
1458
|
updates.current_period_end = setup.period.end;
|
|
1487
1459
|
}
|
|
1488
1460
|
updates.billing_cycle_anchor = setup.cycle.anchor;
|
|
1489
|
-
logger.info('subscription updates on reset anchor', { subscription: req.
|
|
1461
|
+
logger.info('subscription updates on reset anchor', { subscription: c.req.param('id'), updates });
|
|
1490
1462
|
}
|
|
1491
1463
|
|
|
1492
|
-
// handle proration
|
|
1493
1464
|
if (prorationBehavior === 'create_prorations') {
|
|
1494
|
-
// 0. cleanup open invoices
|
|
1495
1465
|
if (subscription.pending_update?.updates?.latest_invoice_id) {
|
|
1496
1466
|
await cleanupInvoiceAndItems(subscription.pending_update?.updates?.latest_invoice_id);
|
|
1497
1467
|
// @ts-ignore
|
|
1498
1468
|
await subscription.update({ pending_update: null });
|
|
1499
1469
|
}
|
|
1500
1470
|
|
|
1501
|
-
// 1. create proration
|
|
1502
1471
|
const { lastInvoice, due, newCredit, appliedCredit, prorations, total } = await createProration(
|
|
1503
1472
|
subscription,
|
|
1504
1473
|
setup,
|
|
@@ -1506,7 +1475,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1506
1475
|
);
|
|
1507
1476
|
|
|
1508
1477
|
if ((total === '0' && isInTrial) || newCredit !== '0') {
|
|
1509
|
-
// 0 amount or new credit means no need to create invoice
|
|
1510
1478
|
await subscription.update(updates);
|
|
1511
1479
|
await finalizeSubscriptionUpdate({
|
|
1512
1480
|
subscription,
|
|
@@ -1520,11 +1488,10 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1520
1488
|
updatedItems,
|
|
1521
1489
|
updates,
|
|
1522
1490
|
});
|
|
1523
|
-
await createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(
|
|
1524
|
-
return
|
|
1491
|
+
await createEvent('Subscription', 'customer.subscription.upgraded', subscription).catch(reportAuditFailure);
|
|
1492
|
+
return c.json({ ...subscription.toJSON(), connectAction });
|
|
1525
1493
|
}
|
|
1526
|
-
|
|
1527
|
-
// 3. create new invoice items: amount according to new subscription items
|
|
1494
|
+
|
|
1528
1495
|
const result = await ensureInvoiceAndItems({
|
|
1529
1496
|
customer,
|
|
1530
1497
|
currency: paymentCurrency,
|
|
@@ -1551,9 +1518,8 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1551
1518
|
});
|
|
1552
1519
|
const { invoice } = result;
|
|
1553
1520
|
updates.latest_invoice_id = invoice.id;
|
|
1554
|
-
logger.info('subscription update invoice created', { subscription: req.
|
|
1521
|
+
logger.info('subscription update invoice created', { subscription: c.req.param('id'), invoice: invoice.id });
|
|
1555
1522
|
|
|
1556
|
-
// 4. create proration invoice items: amount according to proration amount
|
|
1557
1523
|
const prorationInvoiceItems = await Promise.all(
|
|
1558
1524
|
prorations.map((x: any) =>
|
|
1559
1525
|
InvoiceItem.create({
|
|
@@ -1573,15 +1539,13 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1573
1539
|
)
|
|
1574
1540
|
);
|
|
1575
1541
|
logger.info('subscription proration invoice items created', {
|
|
1576
|
-
subscription: req.
|
|
1542
|
+
subscription: c.req.param('id'),
|
|
1577
1543
|
items: prorationInvoiceItems.map((x) => x.id),
|
|
1578
1544
|
});
|
|
1579
1545
|
|
|
1580
|
-
// 5. check do we need to connect
|
|
1581
1546
|
let hasNext = true;
|
|
1582
1547
|
let needsNewStake = false;
|
|
1583
1548
|
|
|
1584
|
-
// Check if stake is required and if we need a new one
|
|
1585
1549
|
const requiresStake = paymentMethod.type === 'arcblock' && !subscription.billing_thresholds?.no_stake;
|
|
1586
1550
|
if (requiresStake) {
|
|
1587
1551
|
const existingStakeAddress = subscription.payment_details?.arcblock?.staking?.address;
|
|
@@ -1603,8 +1567,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1603
1567
|
hasNext = false;
|
|
1604
1568
|
} else {
|
|
1605
1569
|
const payer = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
|
|
1606
|
-
// For change-plan, we should check if delegation is sufficient for the due amount,
|
|
1607
|
-
// not the full new plan price. The due amount is what user actually needs to pay.
|
|
1608
1570
|
const delegation = await isDelegationSufficientForPayment({
|
|
1609
1571
|
paymentMethod,
|
|
1610
1572
|
paymentCurrency,
|
|
@@ -1622,30 +1584,26 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1622
1584
|
needsNewStake,
|
|
1623
1585
|
});
|
|
1624
1586
|
if (delegation.sufficient && !needsNewStake) {
|
|
1625
|
-
// Both delegation is sufficient and no new stake needed
|
|
1626
1587
|
hasNext = false;
|
|
1627
1588
|
} else if (['NO_DID_WALLET'].includes(delegation.reason as string)) {
|
|
1628
1589
|
throw new Error('Subscription update can only be done when you do have connected DID Wallet');
|
|
1629
1590
|
} else if (['NO_TOKEN', 'NO_ENOUGH_TOKEN'].includes(delegation.reason as string)) {
|
|
1630
|
-
// FIXME: this is not supported at frontend
|
|
1631
1591
|
connectAction = 'collect';
|
|
1632
1592
|
} else {
|
|
1633
1593
|
connectAction = 'change-plan';
|
|
1634
1594
|
}
|
|
1635
1595
|
}
|
|
1636
1596
|
|
|
1637
|
-
// 6. adjust invoice total
|
|
1638
1597
|
await invoice.update({
|
|
1639
1598
|
status: 'open',
|
|
1640
1599
|
amount_due: due,
|
|
1641
1600
|
amount_remaining: due,
|
|
1642
1601
|
});
|
|
1643
1602
|
|
|
1644
|
-
// 7. wait for succeed
|
|
1645
1603
|
if (hasNext) {
|
|
1646
1604
|
await subscription.update({
|
|
1647
1605
|
pending_update: {
|
|
1648
|
-
expires_at: dayjs().unix() + 30 * 60,
|
|
1606
|
+
expires_at: dayjs().unix() + 30 * 60,
|
|
1649
1607
|
updates,
|
|
1650
1608
|
appliedCredit,
|
|
1651
1609
|
newCredit,
|
|
@@ -1678,7 +1636,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1678
1636
|
invoice: invoice.id,
|
|
1679
1637
|
});
|
|
1680
1638
|
|
|
1681
|
-
// check if we have succeeded
|
|
1682
1639
|
await Promise.all([invoice.reload(), subscription.reload()]);
|
|
1683
1640
|
|
|
1684
1641
|
if (invoice.status === 'paid') {
|
|
@@ -1703,9 +1660,9 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1703
1660
|
}
|
|
1704
1661
|
}
|
|
1705
1662
|
}
|
|
1706
|
-
} else if (
|
|
1663
|
+
} else if (body.billing_cycle_anchor === 'now') {
|
|
1707
1664
|
if (paymentMethod?.type === 'stripe') {
|
|
1708
|
-
return
|
|
1665
|
+
return c.json({ error: 'Update billing_cycle_anchor not supported for stripe subscriptions' }, 400);
|
|
1709
1666
|
}
|
|
1710
1667
|
|
|
1711
1668
|
if (subscription.isActive() === false) {
|
|
@@ -1715,7 +1672,6 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1715
1672
|
throw new Error('Updating billing_cycle_anchor not allowed for scheduled-to-cancel subscriptions');
|
|
1716
1673
|
}
|
|
1717
1674
|
|
|
1718
|
-
// FIXME: handle billing cycle anchor change without any item change
|
|
1719
1675
|
await subscription.update(updates);
|
|
1720
1676
|
} else {
|
|
1721
1677
|
const now = dayjs().unix();
|
|
@@ -1787,15 +1743,14 @@ router.put('/:id', authPortal, async (req, res) => {
|
|
|
1787
1743
|
updatedFields: Object.keys(updates),
|
|
1788
1744
|
newStatus: subscription.status,
|
|
1789
1745
|
});
|
|
1790
|
-
return
|
|
1791
|
-
} catch (err) {
|
|
1746
|
+
return c.json({ ...subscription.toJSON(), connectAction });
|
|
1747
|
+
} catch (err: any) {
|
|
1792
1748
|
logger.error(err);
|
|
1793
|
-
return
|
|
1749
|
+
return c.json({ code: err.code, error: err.message }, 500);
|
|
1794
1750
|
}
|
|
1795
1751
|
});
|
|
1796
1752
|
|
|
1797
1753
|
const getUpdateTable = async (subscription: Subscription) => {
|
|
1798
|
-
// If we are from a pricing table
|
|
1799
1754
|
if (subscription.metadata.pricing_table_id) {
|
|
1800
1755
|
const table = await PricingTable.findByPk(subscription.metadata.pricing_table_id);
|
|
1801
1756
|
if (table) {
|
|
@@ -1804,7 +1759,6 @@ const getUpdateTable = async (subscription: Subscription) => {
|
|
|
1804
1759
|
}
|
|
1805
1760
|
}
|
|
1806
1761
|
|
|
1807
|
-
// if we are from upsell
|
|
1808
1762
|
const items = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
1809
1763
|
if (items.length === 1) {
|
|
1810
1764
|
const expanded = await Price!.expand(
|
|
@@ -1813,14 +1767,12 @@ const getUpdateTable = async (subscription: Subscription) => {
|
|
|
1813
1767
|
);
|
|
1814
1768
|
const exist = expanded.find((x) => x.price.type === 'recurring' && x.price.upsell?.upsells_to_id);
|
|
1815
1769
|
if (exist && exist.price.upsell?.upsells_to) {
|
|
1816
|
-
// Fake a pricing table here
|
|
1817
1770
|
return {
|
|
1818
1771
|
active: true,
|
|
1819
1772
|
livemode: subscription.livemode,
|
|
1820
1773
|
name: 'Fake Pricing Table',
|
|
1821
1774
|
items: [
|
|
1822
1775
|
{
|
|
1823
|
-
// Current item should always be the first
|
|
1824
1776
|
...PricingTable.formatItem({
|
|
1825
1777
|
price_id: exist.price.id,
|
|
1826
1778
|
product_id: exist.price.product_id,
|
|
@@ -1829,7 +1781,6 @@ const getUpdateTable = async (subscription: Subscription) => {
|
|
|
1829
1781
|
product: exist.price.product,
|
|
1830
1782
|
},
|
|
1831
1783
|
{
|
|
1832
|
-
// Upsell item comes next
|
|
1833
1784
|
...PricingTable.formatItem({
|
|
1834
1785
|
price_id: exist.price.upsell.upsells_to_id,
|
|
1835
1786
|
product_id: exist.price.product_id,
|
|
@@ -1846,69 +1797,65 @@ const getUpdateTable = async (subscription: Subscription) => {
|
|
|
1846
1797
|
};
|
|
1847
1798
|
|
|
1848
1799
|
// Check that the subscription is upgradable
|
|
1849
|
-
|
|
1800
|
+
app.get('/:id/change-plan', authPortal, async (c) => {
|
|
1850
1801
|
try {
|
|
1851
|
-
const subscription = await Subscription.findByPk(req.
|
|
1802
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
1852
1803
|
if (!subscription) {
|
|
1853
|
-
return
|
|
1804
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1854
1805
|
}
|
|
1855
1806
|
if (subscription.isActive() === false) {
|
|
1856
|
-
return
|
|
1807
|
+
return c.json({ error: 'Subscription is not active' }, 400);
|
|
1857
1808
|
}
|
|
1858
1809
|
if (subscription.isScheduledToCancel()) {
|
|
1859
|
-
return
|
|
1810
|
+
return c.json({ error: 'Subscription is scheduled to cancel' }, 400);
|
|
1860
1811
|
}
|
|
1861
1812
|
const locked = await Lock.isLocked(`${subscription.id}-change-plan`);
|
|
1862
1813
|
if (locked) {
|
|
1863
|
-
return
|
|
1814
|
+
return c.json({ error: 'Subscription plan change is not allowed until next billing cycle' }, 400);
|
|
1864
1815
|
}
|
|
1865
1816
|
|
|
1866
1817
|
const table = await getUpdateTable(subscription);
|
|
1867
|
-
return
|
|
1818
|
+
return c.json(table);
|
|
1868
1819
|
} catch (err) {
|
|
1869
1820
|
logger.error(err);
|
|
1870
|
-
return
|
|
1821
|
+
return c.json(null);
|
|
1871
1822
|
}
|
|
1872
1823
|
});
|
|
1873
1824
|
|
|
1874
1825
|
// Simulate subscription plan change
|
|
1875
|
-
|
|
1826
|
+
app.post('/:id/change-plan', authPortal, async (c) => {
|
|
1876
1827
|
try {
|
|
1877
|
-
const subscription = await Subscription.findByPk(req.
|
|
1828
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
1878
1829
|
if (!subscription) {
|
|
1879
|
-
return
|
|
1830
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1880
1831
|
}
|
|
1881
1832
|
if (subscription.isActive() === false) {
|
|
1882
|
-
return
|
|
1833
|
+
return c.json({ error: 'Subscription is not active' }, 400);
|
|
1883
1834
|
}
|
|
1884
1835
|
if (subscription.isScheduledToCancel()) {
|
|
1885
|
-
return
|
|
1836
|
+
return c.json({ error: 'Subscription is scheduled to cancel' }, 400);
|
|
1886
1837
|
}
|
|
1887
1838
|
const locked = await Lock.isLocked(`${subscription.id}-change-plan`);
|
|
1888
1839
|
if (locked) {
|
|
1889
|
-
return
|
|
1840
|
+
return c.json({ error: 'Subscription plan change is not allowed until next billing cycle' }, 400);
|
|
1890
1841
|
}
|
|
1891
1842
|
|
|
1892
|
-
const
|
|
1843
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
1844
|
+
const { error } = updateSchema.validate(body);
|
|
1893
1845
|
if (error) {
|
|
1894
|
-
return
|
|
1846
|
+
return c.json({ error: `Subscription update request invalid: ${error.message}` }, 400);
|
|
1895
1847
|
}
|
|
1896
1848
|
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
return res.status(400).json({ error: 'Subscription update request invalid: items are empty' });
|
|
1849
|
+
if (!Array.isArray(body.items) || !body.items.length) {
|
|
1850
|
+
return c.json({ error: 'Subscription update request invalid: items are empty' }, 400);
|
|
1900
1851
|
}
|
|
1901
1852
|
|
|
1902
|
-
|
|
1903
|
-
const { newItems } = await validateSubscriptionUpdateRequest(subscription, req.body.items);
|
|
1853
|
+
const { newItems } = await validateSubscriptionUpdateRequest(subscription, body.items);
|
|
1904
1854
|
|
|
1905
|
-
// do the simulation
|
|
1906
|
-
// Note: For dynamic pricing, the actual amount is calculated by frontend using current exchange rate
|
|
1907
|
-
// Backend only provides the base structure, frontend handles display with real-time rates
|
|
1908
1855
|
const setup = getSubscriptionCreateSetup(newItems, subscription.currency_id, 0);
|
|
1909
1856
|
const result = await createProration(subscription, setup, dayjs().unix());
|
|
1910
1857
|
|
|
1911
|
-
return
|
|
1858
|
+
return c.json({
|
|
1912
1859
|
setup,
|
|
1913
1860
|
total: result.total,
|
|
1914
1861
|
newCredit: result.newCredit,
|
|
@@ -1918,21 +1865,21 @@ router.post('/:id/change-plan', authPortal, async (req, res) => {
|
|
|
1918
1865
|
prorations: result.prorations,
|
|
1919
1866
|
items: newItems,
|
|
1920
1867
|
});
|
|
1921
|
-
} catch (err) {
|
|
1868
|
+
} catch (err: any) {
|
|
1922
1869
|
logger.error(err);
|
|
1923
|
-
return
|
|
1870
|
+
return c.json({ error: err.message }, 400);
|
|
1924
1871
|
}
|
|
1925
1872
|
});
|
|
1926
1873
|
|
|
1927
1874
|
// Simulate subscription cancel
|
|
1928
|
-
|
|
1875
|
+
app.get('/:id/proration', authPortal, async (c) => {
|
|
1929
1876
|
try {
|
|
1930
|
-
const subscription = await Subscription.findByPk(req.
|
|
1877
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
1931
1878
|
if (!subscription) {
|
|
1932
|
-
return
|
|
1879
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1933
1880
|
}
|
|
1934
1881
|
if (subscription.isActive() === false) {
|
|
1935
|
-
return
|
|
1882
|
+
return c.json({ error: 'Subscription is not active' }, 400);
|
|
1936
1883
|
}
|
|
1937
1884
|
|
|
1938
1885
|
const invoice = await Invoice.findOne({
|
|
@@ -1944,18 +1891,19 @@ router.get('/:id/proration', authPortal, async (req, res) => {
|
|
|
1944
1891
|
order: [['created_at', 'DESC']],
|
|
1945
1892
|
});
|
|
1946
1893
|
|
|
1947
|
-
const
|
|
1894
|
+
const timeQuery = c.req.query('time');
|
|
1895
|
+
const anchor = timeQuery ? dayjs(timeQuery as any).unix() : dayjs().unix();
|
|
1948
1896
|
const result = await getSubscriptionRefundSetup(subscription, anchor, invoice?.currency_id);
|
|
1949
1897
|
if (result.remaining === '0') {
|
|
1950
|
-
return
|
|
1898
|
+
return c.json(null);
|
|
1951
1899
|
}
|
|
1952
1900
|
const paymentCurrency = await PaymentCurrency.findByPk(result.lastInvoice?.currency_id);
|
|
1953
1901
|
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency?.payment_method_id);
|
|
1954
1902
|
if (paymentMethod?.type === 'stripe') {
|
|
1955
|
-
return
|
|
1903
|
+
return c.json({ error: 'Not supported for subscriptions paid with stripe' }, 400);
|
|
1956
1904
|
}
|
|
1957
1905
|
|
|
1958
|
-
return
|
|
1906
|
+
return c.json({
|
|
1959
1907
|
total: result.remaining,
|
|
1960
1908
|
latest: invoice?.total,
|
|
1961
1909
|
unused: result.remainingUnused,
|
|
@@ -1963,67 +1911,68 @@ router.get('/:id/proration', authPortal, async (req, res) => {
|
|
|
1963
1911
|
prorations: result.prorations,
|
|
1964
1912
|
paymentCurrency,
|
|
1965
1913
|
});
|
|
1966
|
-
} catch (err) {
|
|
1914
|
+
} catch (err: any) {
|
|
1967
1915
|
logger.error(err);
|
|
1968
|
-
return
|
|
1916
|
+
return c.json({ error: err.message }, 400);
|
|
1969
1917
|
}
|
|
1970
1918
|
});
|
|
1971
1919
|
|
|
1972
1920
|
// Simulate stake return when subscription is canceled
|
|
1973
|
-
|
|
1921
|
+
app.get('/:id/staking', authPortal, async (c) => {
|
|
1974
1922
|
try {
|
|
1975
|
-
const subscription = await Subscription.findByPk(req.
|
|
1923
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
1976
1924
|
if (!subscription) {
|
|
1977
|
-
return
|
|
1925
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
1978
1926
|
}
|
|
1979
1927
|
|
|
1980
1928
|
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
1981
1929
|
if (paymentMethod?.type !== 'arcblock') {
|
|
1982
|
-
return
|
|
1983
|
-
|
|
1984
|
-
|
|
1930
|
+
return c.json(
|
|
1931
|
+
{ error: `Stake return not supported for subscription with payment method ${paymentMethod?.type}` },
|
|
1932
|
+
400
|
|
1933
|
+
);
|
|
1985
1934
|
}
|
|
1986
1935
|
const address = subscription?.payment_details?.arcblock?.staking?.address ?? undefined;
|
|
1987
1936
|
if (!address) {
|
|
1988
|
-
return
|
|
1937
|
+
return c.json({ error: 'Staking not found on subscription payment detail' }, 400);
|
|
1989
1938
|
}
|
|
1990
1939
|
const returnResult = await getSubscriptionStakeReturnSetup(subscription, address, paymentMethod);
|
|
1991
1940
|
const slashResult = await getSubscriptionStakeSlashSetup(subscription, address, paymentMethod);
|
|
1992
|
-
return
|
|
1941
|
+
return c.json({
|
|
1993
1942
|
return_amount: returnResult.return_amount,
|
|
1994
1943
|
total: returnResult.total,
|
|
1995
1944
|
slash_amount: slashResult.return_amount,
|
|
1996
1945
|
});
|
|
1997
|
-
} catch (err) {
|
|
1946
|
+
} catch (err: any) {
|
|
1998
1947
|
logger.error('subscription staking simulation failed', { error: err });
|
|
1999
1948
|
logger.error(err);
|
|
2000
|
-
return
|
|
1949
|
+
return c.json({ error: err.message }, 400);
|
|
2001
1950
|
}
|
|
2002
1951
|
});
|
|
2003
1952
|
|
|
2004
1953
|
// Check payment change status
|
|
2005
|
-
|
|
2006
|
-
const subscription = await Subscription.findByPk(req.
|
|
1954
|
+
app.get('/:id/change-payment', authPortal, async (c) => {
|
|
1955
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2007
1956
|
if (!subscription) {
|
|
2008
|
-
return
|
|
1957
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2009
1958
|
}
|
|
2010
1959
|
const context = subscription.metadata.changePayment || {};
|
|
2011
1960
|
if (!context.setup_intent_id) {
|
|
2012
|
-
return
|
|
1961
|
+
return c.json({ error: 'Subscription change payment context not found' }, 404);
|
|
2013
1962
|
}
|
|
2014
1963
|
|
|
2015
1964
|
const setupIntent = await SetupIntent.findByPk(context.setup_intent_id);
|
|
2016
|
-
return
|
|
1965
|
+
return c.json({ subscription, setupIntent });
|
|
2017
1966
|
});
|
|
2018
1967
|
|
|
2019
|
-
|
|
1968
|
+
app.get('/:id/exchange-rate', authPortal, async (c) => {
|
|
2020
1969
|
try {
|
|
2021
|
-
const subscription = await Subscription.findByPk(req.
|
|
1970
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2022
1971
|
if (!subscription) {
|
|
2023
|
-
return
|
|
1972
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2024
1973
|
}
|
|
2025
1974
|
|
|
2026
|
-
const currencyId =
|
|
1975
|
+
const currencyId = c.req.query('currency_id') || subscription.currency_id;
|
|
2027
1976
|
const paymentCurrency = (await PaymentCurrency.findByPk(currencyId, {
|
|
2028
1977
|
include: [
|
|
2029
1978
|
{
|
|
@@ -2034,24 +1983,24 @@ router.get('/:id/exchange-rate', authPortal, async (req, res) => {
|
|
|
2034
1983
|
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
2035
1984
|
|
|
2036
1985
|
if (!paymentCurrency) {
|
|
2037
|
-
return
|
|
1986
|
+
return c.json({ error: 'Currency not found' }, 400);
|
|
2038
1987
|
}
|
|
2039
1988
|
|
|
2040
1989
|
const paymentMethod =
|
|
2041
1990
|
paymentCurrency.payment_method || (await PaymentMethod.findByPk(paymentCurrency.payment_method_id));
|
|
2042
1991
|
if (!paymentMethod) {
|
|
2043
|
-
return
|
|
1992
|
+
return c.json({ error: 'Payment method not found' }, 400);
|
|
2044
1993
|
}
|
|
2045
1994
|
|
|
2046
1995
|
if (paymentMethod.type === 'stripe') {
|
|
2047
|
-
return
|
|
1996
|
+
return c.json({ error: 'Stripe currency does not require exchange rate.' }, 400);
|
|
2048
1997
|
}
|
|
2049
1998
|
|
|
2050
1999
|
const rateSymbol = getExchangeRateSymbol(paymentCurrency.symbol, paymentMethod.type as any);
|
|
2051
2000
|
const rateResult = await exchangeRateService.getRate(rateSymbol);
|
|
2052
2001
|
const serverNow = Date.now();
|
|
2053
2002
|
|
|
2054
|
-
return
|
|
2003
|
+
return c.json({
|
|
2055
2004
|
server_now: serverNow,
|
|
2056
2005
|
rate: rateResult.rate,
|
|
2057
2006
|
timestamp_ms: rateResult.timestamp_ms,
|
|
@@ -2067,32 +2016,31 @@ router.get('/:id/exchange-rate', authPortal, async (req, res) => {
|
|
|
2067
2016
|
});
|
|
2068
2017
|
} catch (err: any) {
|
|
2069
2018
|
logger.error('Failed to fetch exchange rate for subscription change payment', {
|
|
2070
|
-
subscriptionId: req.
|
|
2019
|
+
subscriptionId: c.req.param('id'),
|
|
2071
2020
|
error: err.message,
|
|
2072
2021
|
});
|
|
2073
|
-
return
|
|
2022
|
+
return c.json({ error: err.message }, 400);
|
|
2074
2023
|
}
|
|
2075
2024
|
});
|
|
2076
2025
|
|
|
2077
|
-
|
|
2026
|
+
app.put('/:id/slippage', authPortal, async (c) => {
|
|
2078
2027
|
try {
|
|
2079
|
-
const subscription = await Subscription.findByPk(req.
|
|
2028
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2080
2029
|
if (!subscription) {
|
|
2081
|
-
return
|
|
2030
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2082
2031
|
}
|
|
2083
2032
|
|
|
2084
|
-
const
|
|
2085
|
-
const
|
|
2033
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
2034
|
+
const { slippage_percent: slippagePercent } = body;
|
|
2035
|
+
const rawConfig = body?.slippage_config || body?.slippage || null;
|
|
2086
2036
|
const normalizePercent = (value: any) => {
|
|
2087
2037
|
const normalized = typeof value === 'string' ? Number(value) : value;
|
|
2088
|
-
// Only validate that it's a non-negative finite number, no upper limit
|
|
2089
2038
|
if (!Number.isFinite(normalized) || normalized < 0) {
|
|
2090
2039
|
return null;
|
|
2091
2040
|
}
|
|
2092
2041
|
return normalized;
|
|
2093
2042
|
};
|
|
2094
2043
|
|
|
2095
|
-
// Helper: get current exchange rate for subscription currency
|
|
2096
2044
|
const getCurrentRate = async (): Promise<{ rate: string; baseCurrency: string } | null> => {
|
|
2097
2045
|
const currency = (await PaymentCurrency.findByPk(subscription.currency_id, {
|
|
2098
2046
|
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
@@ -2106,13 +2054,11 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2106
2054
|
return { rate: rateResult.rate, baseCurrency: 'USD' };
|
|
2107
2055
|
};
|
|
2108
2056
|
|
|
2109
|
-
// Helper: calculate min_acceptable_rate from percent
|
|
2110
2057
|
const calcMinRateFromPercent = (percent: number, currentRate: string): string => {
|
|
2111
2058
|
const rateNum = Number(currentRate);
|
|
2112
2059
|
if (!Number.isFinite(rateNum) || rateNum <= 0) {
|
|
2113
2060
|
return '0';
|
|
2114
2061
|
}
|
|
2115
|
-
// min_acceptable_rate = current_rate / (1 + percent/100)
|
|
2116
2062
|
const minRate = rateNum / (1 + percent / 100);
|
|
2117
2063
|
return minRate.toFixed(8);
|
|
2118
2064
|
};
|
|
@@ -2122,12 +2068,10 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2122
2068
|
const mode = rawConfig.mode === 'rate' ? 'rate' : 'percent';
|
|
2123
2069
|
const minRate = rawConfig.min_acceptable_rate ?? rawConfig.minAcceptableRate;
|
|
2124
2070
|
|
|
2125
|
-
// For rate mode, min_acceptable_rate is required; percent is derived
|
|
2126
2071
|
if (mode === 'rate') {
|
|
2127
2072
|
if (minRate === undefined || minRate === null || minRate === '') {
|
|
2128
|
-
return
|
|
2073
|
+
return c.json({ error: 'min_acceptable_rate is required for rate mode' }, 400);
|
|
2129
2074
|
}
|
|
2130
|
-
// Accept any non-negative percent value for rate mode (calculated from rate)
|
|
2131
2075
|
const percent = normalizePercent(rawConfig.percent);
|
|
2132
2076
|
const baseCurrency = rawConfig.base_currency ?? rawConfig.baseCurrency ?? 'USD';
|
|
2133
2077
|
const rateInfo = await getCurrentRate();
|
|
@@ -2140,21 +2084,17 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2140
2084
|
updated_at_ms: Date.now(),
|
|
2141
2085
|
};
|
|
2142
2086
|
} else {
|
|
2143
|
-
// Percent mode: validate percent
|
|
2144
2087
|
const percent = normalizePercent(rawConfig.percent);
|
|
2145
2088
|
if (percent === null) {
|
|
2146
|
-
return
|
|
2089
|
+
return c.json({ error: 'slippage_percent must be a non-negative number' }, 400);
|
|
2147
2090
|
}
|
|
2148
2091
|
const baseCurrency = rawConfig.base_currency ?? rawConfig.baseCurrency ?? 'USD';
|
|
2149
|
-
// Use min_acceptable_rate from frontend if provided, otherwise calculate it
|
|
2150
2092
|
const frontendMinRate = rawConfig.min_acceptable_rate ?? rawConfig.minAcceptableRate;
|
|
2151
2093
|
let minAcceptableRate: string | undefined;
|
|
2152
2094
|
let rateAtConfigTime: string | undefined;
|
|
2153
2095
|
if (frontendMinRate) {
|
|
2154
|
-
// Frontend already calculated it - use directly
|
|
2155
2096
|
minAcceptableRate = String(frontendMinRate);
|
|
2156
2097
|
} else {
|
|
2157
|
-
// Frontend didn't provide - calculate using same algorithm
|
|
2158
2098
|
const rateInfo = await getCurrentRate();
|
|
2159
2099
|
if (rateInfo) {
|
|
2160
2100
|
minAcceptableRate = calcMinRateFromPercent(percent, rateInfo.rate);
|
|
@@ -2173,7 +2113,7 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2173
2113
|
} else if (slippagePercent !== undefined && slippagePercent !== null) {
|
|
2174
2114
|
const value = normalizePercent(slippagePercent);
|
|
2175
2115
|
if (value === null) {
|
|
2176
|
-
return
|
|
2116
|
+
return c.json({ error: 'slippage_percent must be a non-negative number' }, 400);
|
|
2177
2117
|
}
|
|
2178
2118
|
const rateInfo = await getCurrentRate();
|
|
2179
2119
|
const minAcceptableRate = rateInfo ? calcMinRateFromPercent(value, rateInfo.rate) : undefined;
|
|
@@ -2186,7 +2126,7 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2186
2126
|
updated_at_ms: Date.now(),
|
|
2187
2127
|
};
|
|
2188
2128
|
} else {
|
|
2189
|
-
return
|
|
2129
|
+
return c.json({ error: 'slippage config is required' }, 400);
|
|
2190
2130
|
}
|
|
2191
2131
|
|
|
2192
2132
|
await subscription.update({ slippage_config: config });
|
|
@@ -2195,7 +2135,6 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2195
2135
|
slippageConfig: config,
|
|
2196
2136
|
});
|
|
2197
2137
|
|
|
2198
|
-
// Check if authorization is sufficient with the new slippage config
|
|
2199
2138
|
let delegationWarning: {
|
|
2200
2139
|
sufficient: boolean;
|
|
2201
2140
|
reason?: string;
|
|
@@ -2208,18 +2147,15 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2208
2147
|
include: [{ model: PaymentMethod, as: 'payment_method' }],
|
|
2209
2148
|
})) as PaymentCurrency & { payment_method: PaymentMethod };
|
|
2210
2149
|
|
|
2211
|
-
// Only check delegation for non-Stripe payment methods
|
|
2212
2150
|
if (paymentCurrency && paymentCurrency.payment_method?.type !== 'stripe') {
|
|
2213
2151
|
const paymentMethod = paymentCurrency.payment_method;
|
|
2214
2152
|
const payer = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
|
|
2215
2153
|
|
|
2216
|
-
// Get subscription items and expand them
|
|
2217
2154
|
const subscriptionItems = await SubscriptionItem.findAll({
|
|
2218
2155
|
where: { subscription_id: subscription.id },
|
|
2219
2156
|
});
|
|
2220
2157
|
const lineItems = await Price.expand(subscriptionItems.map((x) => x.toJSON()));
|
|
2221
2158
|
|
|
2222
|
-
// Calculate the required amount with the new slippage
|
|
2223
2159
|
const requiredAmount = await getFastCheckoutAmount({
|
|
2224
2160
|
items: lineItems,
|
|
2225
2161
|
mode: 'subscription',
|
|
@@ -2227,7 +2163,6 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2227
2163
|
trialing: subscription.status === 'trialing',
|
|
2228
2164
|
});
|
|
2229
2165
|
|
|
2230
|
-
// Check if delegation is sufficient
|
|
2231
2166
|
const delegation = await isDelegationSufficientForPayment({
|
|
2232
2167
|
paymentMethod,
|
|
2233
2168
|
paymentCurrency,
|
|
@@ -2251,73 +2186,70 @@ router.put('/:id/slippage', authPortal, async (req, res) => {
|
|
|
2251
2186
|
}
|
|
2252
2187
|
}
|
|
2253
2188
|
} catch (delegationError: any) {
|
|
2254
|
-
// Don't fail the slippage update if delegation check fails
|
|
2255
2189
|
logger.warn('Failed to check delegation after slippage update', {
|
|
2256
2190
|
subscriptionId: subscription.id,
|
|
2257
2191
|
error: delegationError.message,
|
|
2258
2192
|
});
|
|
2259
2193
|
}
|
|
2260
2194
|
|
|
2261
|
-
return
|
|
2195
|
+
return c.json({
|
|
2262
2196
|
...subscription.toJSON(),
|
|
2263
2197
|
...(delegationWarning ? { delegation_warning: delegationWarning } : {}),
|
|
2264
2198
|
});
|
|
2265
2199
|
} catch (err: any) {
|
|
2266
2200
|
logger.error('Failed to update subscription slippage', {
|
|
2267
|
-
subscriptionId: req.
|
|
2201
|
+
subscriptionId: c.req.param('id'),
|
|
2268
2202
|
error: err.message,
|
|
2269
2203
|
});
|
|
2270
|
-
return
|
|
2204
|
+
return c.json({ error: err.message }, 500);
|
|
2271
2205
|
}
|
|
2272
2206
|
});
|
|
2273
2207
|
|
|
2274
2208
|
// Prepare setupIntent for payment change
|
|
2275
|
-
|
|
2209
|
+
app.post('/:id/change-payment', authPortal, async (c) => {
|
|
2276
2210
|
try {
|
|
2277
|
-
const subscription = await Subscription.findByPk(req.
|
|
2211
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2278
2212
|
if (!subscription) {
|
|
2279
|
-
return
|
|
2213
|
+
return c.json({ error: `Subscription ${c.req.param('id')} not found when change payment` }, 404);
|
|
2280
2214
|
}
|
|
2281
2215
|
if (['active', 'trialing', 'past_due'].includes(subscription.status) === false) {
|
|
2282
|
-
return
|
|
2216
|
+
return c.json({ error: `Subscription ${c.req.param('id')} not active when change payment` }, 400);
|
|
2283
2217
|
}
|
|
2284
|
-
|
|
2218
|
+
|
|
2219
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
2220
|
+
const paymentCurrency = await PaymentCurrency.findByPk(body.payment_currency);
|
|
2285
2221
|
if (!paymentCurrency) {
|
|
2286
|
-
return
|
|
2287
|
-
.status(400)
|
|
2288
|
-
.json({ error: `Payment currency ${req.body.payment_currency} not found when change payment` });
|
|
2222
|
+
return c.json({ error: `Payment currency ${body.payment_currency} not found when change payment` }, 400);
|
|
2289
2223
|
}
|
|
2290
2224
|
const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
|
|
2291
2225
|
if (!paymentMethod) {
|
|
2292
|
-
return
|
|
2293
|
-
.
|
|
2294
|
-
|
|
2226
|
+
return c.json(
|
|
2227
|
+
{ error: `Payment method ${paymentCurrency.payment_method_id} not found when change payment` },
|
|
2228
|
+
400
|
|
2229
|
+
);
|
|
2295
2230
|
}
|
|
2296
2231
|
|
|
2297
2232
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
2298
2233
|
if (paymentMethod.type === 'stripe') {
|
|
2299
|
-
await customer?.update({ address: Object.assign({}, customer.address,
|
|
2234
|
+
await customer?.update({ address: Object.assign({}, customer.address, body.billing_address) });
|
|
2300
2235
|
}
|
|
2301
2236
|
|
|
2302
2237
|
if (subscription.currency_id === paymentCurrency.id) {
|
|
2303
|
-
return
|
|
2238
|
+
return c.json({ error: 'Payment currency not changed when change payment' }, 400);
|
|
2304
2239
|
}
|
|
2305
2240
|
|
|
2306
2241
|
const previousPaymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
2307
2242
|
if (previousPaymentMethod?.type === 'stripe') {
|
|
2308
2243
|
if (!subscription.payment_details?.stripe?.subscription_id) {
|
|
2309
|
-
return
|
|
2244
|
+
return c.json({ error: 'Can not change from stripe without stripe subscription id' }, 400);
|
|
2310
2245
|
}
|
|
2311
2246
|
}
|
|
2312
2247
|
|
|
2313
|
-
// ensure setupIntent
|
|
2314
2248
|
const context = subscription.metadata.changePayment || {};
|
|
2315
2249
|
let setupIntent: SetupIntent | null = null;
|
|
2316
2250
|
if (context.setup_intent_id) {
|
|
2317
|
-
// should be cleared after success
|
|
2318
2251
|
setupIntent = await SetupIntent.findByPk(context.setup_intent_id);
|
|
2319
2252
|
}
|
|
2320
|
-
// Reuse existing setupIntent if not succeeded
|
|
2321
2253
|
if (setupIntent && setupIntent.status !== 'succeeded') {
|
|
2322
2254
|
await setupIntent.update({
|
|
2323
2255
|
status: 'requires_capture',
|
|
@@ -2352,7 +2284,6 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2352
2284
|
},
|
|
2353
2285
|
});
|
|
2354
2286
|
|
|
2355
|
-
// persist setup intent id
|
|
2356
2287
|
await subscription.update({
|
|
2357
2288
|
metadata: { ...subscription.metadata, changePayment: { setup_intent_id: setupIntent.id } },
|
|
2358
2289
|
});
|
|
@@ -2363,7 +2294,6 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2363
2294
|
});
|
|
2364
2295
|
}
|
|
2365
2296
|
|
|
2366
|
-
// if we can complete purchase without any wallet interaction
|
|
2367
2297
|
const subscriptionItems = await SubscriptionItem.findAll({ where: { subscription_id: subscription.id } });
|
|
2368
2298
|
const lineItems = await Price.expand(
|
|
2369
2299
|
subscriptionItems.map((x) => ({ id: x.id, price_id: x.price_id, quantity: x.quantity }))
|
|
@@ -2393,7 +2323,7 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2393
2323
|
publishable_key: settings.stripe?.publishable_key,
|
|
2394
2324
|
status: exist.status,
|
|
2395
2325
|
};
|
|
2396
|
-
return
|
|
2326
|
+
return c.json({
|
|
2397
2327
|
setupIntent,
|
|
2398
2328
|
stripeContext,
|
|
2399
2329
|
subscription,
|
|
@@ -2434,7 +2364,6 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2434
2364
|
} else {
|
|
2435
2365
|
const settings = PaymentMethod.decryptSettings(paymentMethod.settings);
|
|
2436
2366
|
|
|
2437
|
-
// changing from crypto to stripe: create/resume stripe subscription, pause crypto subscription
|
|
2438
2367
|
const stripeSubscription = await ensureStripeSubscription(
|
|
2439
2368
|
subscription,
|
|
2440
2369
|
paymentMethod,
|
|
@@ -2477,10 +2406,8 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2477
2406
|
});
|
|
2478
2407
|
}
|
|
2479
2408
|
} else {
|
|
2480
|
-
// changing from crypto to crypto: just update the subscription
|
|
2481
2409
|
const payer = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
|
|
2482
2410
|
|
|
2483
|
-
// Calculate required amount considering slippage_config for dynamic pricing
|
|
2484
2411
|
const slippageConfig = subscription?.slippage_config;
|
|
2485
2412
|
let requiredAmount: string;
|
|
2486
2413
|
if (slippageConfig?.min_acceptable_rate) {
|
|
@@ -2535,8 +2462,6 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2535
2462
|
});
|
|
2536
2463
|
}
|
|
2537
2464
|
|
|
2538
|
-
// NOTE: this should only happen when local subscription is updated
|
|
2539
|
-
// changing from stripe to crypto: pause stripe subscription
|
|
2540
2465
|
if (previousPaymentMethod!.type === 'stripe' && setupIntent.status === 'succeeded') {
|
|
2541
2466
|
const client = await previousPaymentMethod?.getStripeClient();
|
|
2542
2467
|
const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id as string;
|
|
@@ -2553,25 +2478,25 @@ router.post('/:id/change-payment', authPortal, async (req, res) => {
|
|
|
2553
2478
|
}
|
|
2554
2479
|
}
|
|
2555
2480
|
|
|
2556
|
-
return
|
|
2481
|
+
return c.json({
|
|
2557
2482
|
setupIntent,
|
|
2558
2483
|
stripeContext,
|
|
2559
2484
|
subscription,
|
|
2560
2485
|
customer,
|
|
2561
2486
|
delegation,
|
|
2562
2487
|
});
|
|
2563
|
-
} catch (err) {
|
|
2488
|
+
} catch (err: any) {
|
|
2564
2489
|
logger.error(err);
|
|
2565
|
-
return
|
|
2490
|
+
return c.json({ code: err.code, error: err.message }, 500);
|
|
2566
2491
|
}
|
|
2567
2492
|
});
|
|
2493
|
+
|
|
2568
2494
|
// FIXME: this should be removed in future
|
|
2569
2495
|
// Clean up subscriptions that have invalid invoices and payments
|
|
2570
|
-
|
|
2571
|
-
const status = String(req.query
|
|
2496
|
+
app.delete('/cleanup', auth, async (c) => {
|
|
2497
|
+
const status = String(c.req.query('status') || 'uncollectible');
|
|
2572
2498
|
if (['open', 'uncollectible'].includes(status) === false) {
|
|
2573
|
-
|
|
2574
|
-
return;
|
|
2499
|
+
return c.json({ error: 'status must be either open or uncollectible' });
|
|
2575
2500
|
}
|
|
2576
2501
|
|
|
2577
2502
|
const subscriptions = await Subscription.findAll();
|
|
@@ -2609,19 +2534,19 @@ router.delete('/cleanup', auth, async (req, res) => {
|
|
|
2609
2534
|
})
|
|
2610
2535
|
);
|
|
2611
2536
|
|
|
2612
|
-
|
|
2537
|
+
return c.json(canceled);
|
|
2613
2538
|
});
|
|
2614
2539
|
|
|
2615
2540
|
// Delete subscription and all related data
|
|
2616
|
-
|
|
2617
|
-
if (
|
|
2618
|
-
return
|
|
2541
|
+
app.delete('/:id', auth, async (c) => {
|
|
2542
|
+
if (isProduction()) {
|
|
2543
|
+
return c.json({ error: 'Subscription delete not allowed in production' }, 404);
|
|
2619
2544
|
}
|
|
2620
2545
|
|
|
2621
|
-
const doc = await Subscription.findByPk(req.
|
|
2546
|
+
const doc = await Subscription.findByPk(c.req.param('id'));
|
|
2622
2547
|
|
|
2623
2548
|
if (!doc) {
|
|
2624
|
-
return
|
|
2549
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2625
2550
|
}
|
|
2626
2551
|
|
|
2627
2552
|
await InvoiceItem.destroy({ where: { subscription_id: doc.id } });
|
|
@@ -2631,29 +2556,68 @@ router.delete('/:id', auth, async (req, res) => {
|
|
|
2631
2556
|
await SubscriptionItem.destroy({ where: { subscription_id: doc.id } });
|
|
2632
2557
|
await doc.destroy();
|
|
2633
2558
|
logger.info('Subscription deleted successfully', {
|
|
2634
|
-
subscriptionId: req.
|
|
2559
|
+
subscriptionId: c.req.param('id'),
|
|
2635
2560
|
deletedRelatedRecords: {
|
|
2636
2561
|
invoiceItems: await InvoiceItem.count({ where: { subscription_id: doc.id } }),
|
|
2637
2562
|
invoices: await Invoice.count({ where: { subscription_id: doc.id } }),
|
|
2638
2563
|
usageRecords: await UsageRecord.count({ where: { subscription_item_id: items.map((x) => x.id) } }),
|
|
2639
2564
|
subscriptionItems: items.length,
|
|
2640
2565
|
},
|
|
2641
|
-
requestedBy:
|
|
2566
|
+
requestedBy: c.get('user')?.did,
|
|
2642
2567
|
});
|
|
2643
|
-
return
|
|
2568
|
+
return c.json(doc);
|
|
2644
2569
|
});
|
|
2645
2570
|
|
|
2646
|
-
// Get usage records
|
|
2647
|
-
|
|
2648
|
-
|
|
2571
|
+
// Get usage records — inline since usage-records.ts is not yet a hono module
|
|
2572
|
+
const UsageRecordScheme = Joi.object({
|
|
2573
|
+
subscription_item_id: Joi.string().required(),
|
|
2574
|
+
start: Joi.number().optional(),
|
|
2575
|
+
end: Joi.number().optional(),
|
|
2576
|
+
livemode: Joi.boolean().empty('').optional(),
|
|
2577
|
+
q: Joi.string().empty('').optional(),
|
|
2578
|
+
o: Joi.string().empty('').optional(),
|
|
2579
|
+
}).unknown(true);
|
|
2580
|
+
|
|
2581
|
+
app.get('/:id/usage-records', authPortal, async (c) => {
|
|
2582
|
+
const doc = c.get('doc') as Subscription | undefined;
|
|
2583
|
+
const { error, value: query } = UsageRecordScheme.validate(c.req.query(), { stripUnknown: true });
|
|
2584
|
+
if (error) {
|
|
2585
|
+
return c.json({ error: `usage record request query invalid: ${error.message}` }, 400);
|
|
2586
|
+
}
|
|
2587
|
+
try {
|
|
2588
|
+
const item = await SubscriptionItem.findByPk(query.subscription_item_id);
|
|
2589
|
+
if (!item) {
|
|
2590
|
+
return c.json({ error: `SubscriptionItem not found: ${query.subscription_item_id}` }, 400);
|
|
2591
|
+
}
|
|
2592
|
+
const subscription = doc || (await Subscription.findByPk(item.subscription_id));
|
|
2593
|
+
if (!subscription) {
|
|
2594
|
+
return c.json({ error: `Subscription not found: ${item.subscription_id}` }, 400);
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
const { rows: list, count } = await UsageRecord.findAndCountAll({
|
|
2598
|
+
where: {
|
|
2599
|
+
subscription_item_id: query.subscription_item_id,
|
|
2600
|
+
timestamp: {
|
|
2601
|
+
[Op.gt]: query.start || subscription.current_period_start,
|
|
2602
|
+
[Op.lte]: query.end || subscription.current_period_end,
|
|
2603
|
+
},
|
|
2604
|
+
},
|
|
2605
|
+
order: [['created_at', 'ASC']],
|
|
2606
|
+
});
|
|
2607
|
+
|
|
2608
|
+
return c.json({ count, list });
|
|
2609
|
+
} catch (err) {
|
|
2610
|
+
logger.error(err);
|
|
2611
|
+
return c.json({ count: 0, list: [] });
|
|
2612
|
+
}
|
|
2649
2613
|
});
|
|
2650
2614
|
|
|
2651
2615
|
// Get invoice summary
|
|
2652
|
-
|
|
2616
|
+
app.get('/:id/summary', authPortal, async (c) => {
|
|
2653
2617
|
try {
|
|
2654
|
-
const subscription = await Subscription.findByPk(req.
|
|
2618
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2655
2619
|
if (!subscription) {
|
|
2656
|
-
return
|
|
2620
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2657
2621
|
}
|
|
2658
2622
|
|
|
2659
2623
|
const [summary] = await Invoice.getUncollectibleAmount({
|
|
@@ -2661,96 +2625,95 @@ router.get('/:id/summary', authPortal, async (req, res) => {
|
|
|
2661
2625
|
currencyId: subscription.currency_id,
|
|
2662
2626
|
customerId: subscription.customer_id,
|
|
2663
2627
|
});
|
|
2664
|
-
return
|
|
2665
|
-
} catch (err) {
|
|
2628
|
+
return c.json(summary);
|
|
2629
|
+
} catch (err: any) {
|
|
2666
2630
|
logger.error(err);
|
|
2667
|
-
return
|
|
2631
|
+
return c.json({ error: err.message }, 400);
|
|
2668
2632
|
}
|
|
2669
2633
|
});
|
|
2670
2634
|
|
|
2671
2635
|
// Get upcoming invoice amount
|
|
2672
|
-
|
|
2636
|
+
app.get('/:id/upcoming', authPortal, async (c) => {
|
|
2673
2637
|
try {
|
|
2674
|
-
const result = await getUpcomingInvoiceAmount(req.
|
|
2675
|
-
return
|
|
2676
|
-
} catch (err) {
|
|
2638
|
+
const result = await getUpcomingInvoiceAmount(c.req.param('id') as string);
|
|
2639
|
+
return c.json(result);
|
|
2640
|
+
} catch (err: any) {
|
|
2677
2641
|
logger.error(err);
|
|
2678
|
-
return
|
|
2642
|
+
return c.json({ error: err.message });
|
|
2679
2643
|
}
|
|
2680
2644
|
});
|
|
2681
2645
|
|
|
2682
|
-
|
|
2683
|
-
const subscription = await Subscription.findByPk(req.
|
|
2646
|
+
app.get('/:id/cycle-amount', authPortal, async (c) => {
|
|
2647
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2684
2648
|
if (!subscription) {
|
|
2685
|
-
return
|
|
2649
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2686
2650
|
}
|
|
2687
2651
|
const currency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
2688
2652
|
if (!currency) {
|
|
2689
|
-
return
|
|
2653
|
+
return c.json({ error: 'Currency not found' }, 404);
|
|
2690
2654
|
}
|
|
2691
2655
|
try {
|
|
2692
|
-
// get upcoming invoice
|
|
2693
2656
|
const result = await getUpcomingInvoiceAmount(subscription.id);
|
|
2694
|
-
// get past invoices
|
|
2695
2657
|
const pastMaxAmount = await getPastInvoicesAmount(subscription.id, 'max');
|
|
2696
2658
|
|
|
2697
|
-
// return max amount
|
|
2698
2659
|
const nextAmount = new BN(result.amount === '0' ? result.minExpectedAmount : result.amount).toString();
|
|
2699
2660
|
|
|
2700
2661
|
const maxAmount = new BN(pastMaxAmount.amount).lte(new BN(nextAmount)) ? nextAmount : pastMaxAmount.amount;
|
|
2701
2662
|
|
|
2702
|
-
if (req.query
|
|
2663
|
+
if (c.req.query('overdraftProtection')) {
|
|
2703
2664
|
const { price } = await ensureOverdraftProtectionPrice(subscription.livemode);
|
|
2704
2665
|
const invoicePrice = (price?.currency_options || []).find(
|
|
2705
2666
|
(x: any) => x.currency_id === subscription?.currency_id
|
|
2706
2667
|
);
|
|
2707
2668
|
const gas = invoicePrice?.unit_amount;
|
|
2708
|
-
return
|
|
2669
|
+
return c.json({
|
|
2709
2670
|
amount: new BN(maxAmount).add(new BN(gas)).toString(),
|
|
2710
2671
|
gas,
|
|
2711
2672
|
currency,
|
|
2712
2673
|
});
|
|
2713
2674
|
}
|
|
2714
|
-
return
|
|
2675
|
+
return c.json({
|
|
2715
2676
|
amount: maxAmount,
|
|
2716
2677
|
currency,
|
|
2717
2678
|
});
|
|
2718
|
-
} catch (err) {
|
|
2679
|
+
} catch (err: any) {
|
|
2719
2680
|
logger.error(err);
|
|
2720
|
-
return
|
|
2681
|
+
return c.json({ error: err.message }, 400);
|
|
2721
2682
|
}
|
|
2722
2683
|
});
|
|
2723
2684
|
|
|
2724
2685
|
// slash stake
|
|
2725
|
-
|
|
2726
|
-
const
|
|
2686
|
+
app.put('/:id/slash-stake', auth, async (c) => {
|
|
2687
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
2688
|
+
const { error: slashReasonError } = SlashStakeSchema.validate(body?.slashReason);
|
|
2727
2689
|
if (slashReasonError) {
|
|
2728
|
-
return
|
|
2690
|
+
return c.json({ error: `slash reason invalid: ${slashReasonError.message}` }, 400);
|
|
2729
2691
|
}
|
|
2730
|
-
const subscription = await Subscription.findByPk(req.
|
|
2692
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2731
2693
|
if (!subscription) {
|
|
2732
|
-
return
|
|
2694
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2733
2695
|
}
|
|
2734
2696
|
|
|
2735
2697
|
if (subscription.status !== 'canceled') {
|
|
2736
|
-
return
|
|
2698
|
+
return c.json({ error: `Subscription for ${subscription.id} not canceled` }, 400);
|
|
2737
2699
|
}
|
|
2738
2700
|
|
|
2739
2701
|
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
2740
2702
|
if (paymentMethod?.type !== 'arcblock') {
|
|
2741
|
-
return
|
|
2742
|
-
|
|
2743
|
-
|
|
2703
|
+
return c.json(
|
|
2704
|
+
{ error: `Stake slash not supported for subscription with payment method ${paymentMethod?.type}` },
|
|
2705
|
+
400
|
|
2706
|
+
);
|
|
2744
2707
|
}
|
|
2745
2708
|
const address = subscription?.payment_details?.arcblock?.staking?.address ?? undefined;
|
|
2746
2709
|
if (!address) {
|
|
2747
|
-
return
|
|
2710
|
+
return c.json({ error: 'Staking not found on subscription payment detail' }, 400);
|
|
2748
2711
|
}
|
|
2749
2712
|
try {
|
|
2750
2713
|
logger.warn('Stake slash initiated', {
|
|
2751
2714
|
subscriptionId: subscription.id,
|
|
2752
|
-
slashReason:
|
|
2753
|
-
requestedBy:
|
|
2715
|
+
slashReason: body.slashReason,
|
|
2716
|
+
requestedBy: c.get('user')?.did,
|
|
2754
2717
|
});
|
|
2755
2718
|
|
|
2756
2719
|
await subscription.update({
|
|
@@ -2758,7 +2721,7 @@ router.put('/:id/slash-stake', auth, async (req, res) => {
|
|
|
2758
2721
|
cancelation_details: {
|
|
2759
2722
|
...subscription.cancelation_details,
|
|
2760
2723
|
slash_stake: true,
|
|
2761
|
-
slash_reason:
|
|
2724
|
+
slash_reason: body.slashReason,
|
|
2762
2725
|
},
|
|
2763
2726
|
});
|
|
2764
2727
|
const result = await slashStakeQueue.pushAndWait({
|
|
@@ -2768,41 +2731,41 @@ router.put('/:id/slash-stake', auth, async (req, res) => {
|
|
|
2768
2731
|
logger.info('Stake slash scheduled successfully', {
|
|
2769
2732
|
subscriptionId: subscription.id,
|
|
2770
2733
|
result,
|
|
2771
|
-
slashReason:
|
|
2772
|
-
requestedBy:
|
|
2734
|
+
slashReason: body.slashReason,
|
|
2735
|
+
requestedBy: c.get('user')?.did,
|
|
2773
2736
|
stakingAddress: address,
|
|
2774
2737
|
});
|
|
2775
|
-
return
|
|
2776
|
-
} catch (err) {
|
|
2738
|
+
return c.json(result);
|
|
2739
|
+
} catch (err: any) {
|
|
2777
2740
|
logger.error('subscription slash stake failed', { subscription: subscription.id, error: err });
|
|
2778
|
-
return
|
|
2741
|
+
return c.json({ error: err.message }, 400);
|
|
2779
2742
|
}
|
|
2780
2743
|
});
|
|
2781
2744
|
|
|
2782
2745
|
// get payer token
|
|
2783
|
-
|
|
2784
|
-
const subscription = await Subscription.findByPk(req.
|
|
2746
|
+
app.get('/:id/payer-token', authMine, async (c) => {
|
|
2747
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2785
2748
|
if (!subscription) {
|
|
2786
|
-
return
|
|
2749
|
+
return c.json({ error: `Subscription(${c.req.param('id')}) not found` }, 400);
|
|
2787
2750
|
}
|
|
2788
2751
|
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
2789
2752
|
if (!paymentMethod) {
|
|
2790
|
-
return
|
|
2753
|
+
return c.json({ error: `Payment method(${subscription.default_payment_method_id}) not found` }, 400);
|
|
2791
2754
|
}
|
|
2792
2755
|
|
|
2793
2756
|
const paymentCurrency = await PaymentCurrency.findByPk(subscription.currency_id);
|
|
2794
2757
|
if (!paymentCurrency) {
|
|
2795
|
-
return
|
|
2758
|
+
return c.json({ error: `Payment currency(${subscription.currency_id}) not found` }, 400);
|
|
2796
2759
|
}
|
|
2797
2760
|
|
|
2798
2761
|
// @ts-ignore
|
|
2799
2762
|
const paymentAddress = getSubscriptionPaymentAddress(subscription, paymentMethod.type);
|
|
2800
2763
|
if (!paymentAddress && CHARGE_SUPPORTED_CHAIN_TYPES.includes(paymentMethod.type)) {
|
|
2801
|
-
return
|
|
2764
|
+
return c.json({ error: `Payer not found on subscription payment detail: ${subscription.id}` }, 400);
|
|
2802
2765
|
}
|
|
2803
2766
|
|
|
2804
2767
|
const token = await getTokenByAddress(paymentAddress, paymentMethod, paymentCurrency);
|
|
2805
|
-
return
|
|
2768
|
+
return c.json({ token, paymentAddress });
|
|
2806
2769
|
});
|
|
2807
2770
|
|
|
2808
2771
|
const rechargeSchema = createListParamSchema<{
|
|
@@ -2814,12 +2777,13 @@ const rechargeSchema = createListParamSchema<{
|
|
|
2814
2777
|
customer_did: Joi.string().empty(''),
|
|
2815
2778
|
currency_id: Joi.string().empty(''),
|
|
2816
2779
|
});
|
|
2817
|
-
|
|
2818
|
-
|
|
2780
|
+
|
|
2781
|
+
app.get('/:id/recharge', authMine, async (c) => {
|
|
2782
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2819
2783
|
if (!subscription) {
|
|
2820
|
-
return
|
|
2784
|
+
return c.json({ error: `Subscription(${c.req.param('id')}) not found` }, 404);
|
|
2821
2785
|
}
|
|
2822
|
-
const { page, pageSize, ...query } = await rechargeSchema.validateAsync(req.query, {
|
|
2786
|
+
const { page, pageSize, ...query } = await rechargeSchema.validateAsync(c.req.query(), {
|
|
2823
2787
|
stripUnknown: false,
|
|
2824
2788
|
allowUnknown: true,
|
|
2825
2789
|
});
|
|
@@ -2835,31 +2799,32 @@ router.get('/:id/recharge', authMine, async (req, res) => {
|
|
|
2835
2799
|
},
|
|
2836
2800
|
offset: (page - 1) * pageSize,
|
|
2837
2801
|
limit: pageSize,
|
|
2838
|
-
order: getOrder(req.query, [['created_at', 'DESC']]),
|
|
2802
|
+
order: getOrder(c.req.query(), [['created_at', 'DESC']]),
|
|
2839
2803
|
include: [
|
|
2840
2804
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
2841
2805
|
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
2842
2806
|
],
|
|
2843
2807
|
});
|
|
2844
2808
|
|
|
2845
|
-
return
|
|
2846
|
-
} catch (err) {
|
|
2809
|
+
return c.json({ count, list: invoices, subscription, paging: { page, pageSize } });
|
|
2810
|
+
} catch (err: any) {
|
|
2847
2811
|
logger.error(err);
|
|
2848
|
-
return
|
|
2812
|
+
return c.json({ error: err.message }, 400);
|
|
2849
2813
|
}
|
|
2850
2814
|
});
|
|
2851
2815
|
|
|
2852
|
-
|
|
2816
|
+
app.get('/:id/overdue/invoices', authPortal, async (c) => {
|
|
2853
2817
|
try {
|
|
2854
|
-
const subscription = await Subscription.findByPk(req.
|
|
2818
|
+
const subscription = await Subscription.findByPk(c.req.param('id'), {
|
|
2855
2819
|
include: [{ model: Customer, as: 'customer' }],
|
|
2856
2820
|
});
|
|
2857
2821
|
if (!subscription) {
|
|
2858
|
-
return
|
|
2822
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2859
2823
|
}
|
|
2824
|
+
const user = c.get('user');
|
|
2860
2825
|
// @ts-ignore
|
|
2861
|
-
if (subscription.customer?.did !==
|
|
2862
|
-
return
|
|
2826
|
+
if (subscription.customer?.did !== user?.did && !['admin', 'owner'].includes(user?.role)) {
|
|
2827
|
+
return c.json({ error: 'You are not allowed to access this subscription' }, 403);
|
|
2863
2828
|
}
|
|
2864
2829
|
const { rows: invoices, count } = await Invoice.findAndCountAll({
|
|
2865
2830
|
where: {
|
|
@@ -2873,7 +2838,7 @@ router.get('/:id/overdue/invoices', authPortal, async (req, res) => {
|
|
|
2873
2838
|
],
|
|
2874
2839
|
});
|
|
2875
2840
|
if (count === 0) {
|
|
2876
|
-
return
|
|
2841
|
+
return c.json({ subscription, invoices: [], summary: null });
|
|
2877
2842
|
}
|
|
2878
2843
|
const summary: Record<string, { amount: string; currency: PaymentCurrency; method: PaymentMethod }> = {};
|
|
2879
2844
|
invoices.forEach((invoice) => {
|
|
@@ -2894,27 +2859,27 @@ router.get('/:id/overdue/invoices', authPortal, async (req, res) => {
|
|
|
2894
2859
|
.toString();
|
|
2895
2860
|
}
|
|
2896
2861
|
});
|
|
2897
|
-
return
|
|
2862
|
+
return c.json({
|
|
2898
2863
|
subscription,
|
|
2899
2864
|
summary,
|
|
2900
2865
|
invoices,
|
|
2901
2866
|
});
|
|
2902
|
-
} catch (err) {
|
|
2867
|
+
} catch (err: any) {
|
|
2903
2868
|
logger.error(err);
|
|
2904
|
-
return
|
|
2869
|
+
return c.json({ error: err.message }, 400);
|
|
2905
2870
|
}
|
|
2906
2871
|
});
|
|
2907
2872
|
|
|
2908
|
-
|
|
2873
|
+
app.get('/:id/delegation', authPortal, async (c) => {
|
|
2909
2874
|
try {
|
|
2910
|
-
const subscription = (await Subscription.findByPk(req.
|
|
2875
|
+
const subscription = (await Subscription.findByPk(c.req.param('id'), {
|
|
2911
2876
|
include: [
|
|
2912
2877
|
{ model: PaymentCurrency, as: 'paymentCurrency' },
|
|
2913
2878
|
{ model: PaymentMethod, as: 'paymentMethod' },
|
|
2914
2879
|
],
|
|
2915
2880
|
})) as (Subscription & { paymentMethod: PaymentMethod; paymentCurrency: PaymentCurrency }) | null;
|
|
2916
2881
|
if (!subscription) {
|
|
2917
|
-
return
|
|
2882
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2918
2883
|
}
|
|
2919
2884
|
const payer = getSubscriptionPaymentAddress(subscription, subscription.paymentMethod?.type);
|
|
2920
2885
|
const delegator = await isDelegationSufficientForPayment({
|
|
@@ -2935,28 +2900,28 @@ router.get('/:id/delegation', authPortal, async (req, res) => {
|
|
|
2935
2900
|
'NO_ENOUGH_TOKEN',
|
|
2936
2901
|
].includes(delegator?.reason || '')
|
|
2937
2902
|
) {
|
|
2938
|
-
return
|
|
2903
|
+
return c.json(delegator);
|
|
2939
2904
|
}
|
|
2940
|
-
return
|
|
2905
|
+
return c.json(null);
|
|
2941
2906
|
} catch (err) {
|
|
2942
2907
|
logger.error(err);
|
|
2943
|
-
return
|
|
2908
|
+
return c.json(null);
|
|
2944
2909
|
}
|
|
2945
2910
|
});
|
|
2946
2911
|
|
|
2947
|
-
|
|
2948
|
-
const subscription = await Subscription.findByPk(req.
|
|
2912
|
+
app.get('/:id/overdraft-protection', authPortal, async (c) => {
|
|
2913
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2949
2914
|
if (!subscription) {
|
|
2950
|
-
return
|
|
2915
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2951
2916
|
}
|
|
2952
2917
|
try {
|
|
2953
2918
|
const { enabled, remaining, unused, used, shouldPay } =
|
|
2954
2919
|
await isSubscriptionOverdraftProtectionEnabled(subscription);
|
|
2955
|
-
const upcoming = await getUpcomingInvoiceAmount(req.
|
|
2920
|
+
const upcoming = await getUpcomingInvoiceAmount(c.req.param('id') as string);
|
|
2956
2921
|
const { price } = await ensureOverdraftProtectionPrice(subscription.livemode);
|
|
2957
2922
|
const invoicePrice = (price?.currency_options || []).find((x: any) => x.currency_id === subscription?.currency_id);
|
|
2958
2923
|
const gas = invoicePrice?.unit_amount;
|
|
2959
|
-
return
|
|
2924
|
+
return c.json({
|
|
2960
2925
|
enabled,
|
|
2961
2926
|
remaining,
|
|
2962
2927
|
unused,
|
|
@@ -2965,9 +2930,9 @@ router.get('/:id/overdraft-protection', authPortal, async (req, res) => {
|
|
|
2965
2930
|
gas,
|
|
2966
2931
|
shouldPay,
|
|
2967
2932
|
});
|
|
2968
|
-
} catch (err) {
|
|
2933
|
+
} catch (err: any) {
|
|
2969
2934
|
logger.error(err);
|
|
2970
|
-
return
|
|
2935
|
+
return c.json({ error: err.message }, 400);
|
|
2971
2936
|
}
|
|
2972
2937
|
});
|
|
2973
2938
|
|
|
@@ -2976,36 +2941,37 @@ const overdraftProtectionSchema = Joi.object({
|
|
|
2976
2941
|
enabled: Joi.boolean().required(),
|
|
2977
2942
|
return_stake: Joi.boolean().empty(false).optional(),
|
|
2978
2943
|
}).unknown(true);
|
|
2944
|
+
|
|
2979
2945
|
// 订阅保护
|
|
2980
|
-
|
|
2946
|
+
app.post('/:id/overdraft-protection', authPortal, async (c) => {
|
|
2981
2947
|
try {
|
|
2948
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
2982
2949
|
const {
|
|
2983
2950
|
error: overdraftProtectionError,
|
|
2984
2951
|
value: { amount, return_stake: returnStake, enabled },
|
|
2985
|
-
} = overdraftProtectionSchema.validate(
|
|
2952
|
+
} = overdraftProtectionSchema.validate(body);
|
|
2986
2953
|
if (overdraftProtectionError) {
|
|
2987
|
-
return
|
|
2954
|
+
return c.json({ error: `Overdraft protection invalid: ${overdraftProtectionError.message}` }, 400);
|
|
2988
2955
|
}
|
|
2989
2956
|
|
|
2990
|
-
const subscription = await Subscription.findByPk(req.
|
|
2957
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
2991
2958
|
if (!subscription) {
|
|
2992
|
-
return
|
|
2959
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
2993
2960
|
}
|
|
2994
2961
|
const previousOverdraftProtection = {
|
|
2995
2962
|
enabled: subscription.overdraft_protection?.enabled || false,
|
|
2996
2963
|
payment_method_id: subscription.overdraft_protection?.payment_method_id || null,
|
|
2997
2964
|
payment_details: subscription.overdraft_protection?.payment_details || null,
|
|
2998
2965
|
};
|
|
2999
|
-
const customer = await Customer.findByPkOrDid(
|
|
2966
|
+
const customer = await Customer.findByPkOrDid(c.get('user')?.did as string);
|
|
3000
2967
|
if (!customer) {
|
|
3001
|
-
return
|
|
2968
|
+
return c.json({ error: 'Customer not found' }, 404);
|
|
3002
2969
|
}
|
|
3003
2970
|
const { remaining, used, unused } = await isSubscriptionOverdraftProtectionEnabled(subscription);
|
|
3004
2971
|
if (unused === '0' && !amount && enabled) {
|
|
3005
|
-
return
|
|
2972
|
+
return c.json({ error: 'Please add stake to enable SubGuard™' }, 400);
|
|
3006
2973
|
}
|
|
3007
2974
|
if (returnStake && remaining !== '0' && !enabled) {
|
|
3008
|
-
// disable overdraft protection
|
|
3009
2975
|
await subscription.update({
|
|
3010
2976
|
// @ts-ignore
|
|
3011
2977
|
overdraft_protection: {
|
|
@@ -3019,22 +2985,21 @@ router.post('/:id/overdraft-protection', authPortal, async (req, res) => {
|
|
|
3019
2985
|
});
|
|
3020
2986
|
logger.info('Return overdraft protection stake scheduled', {
|
|
3021
2987
|
subscriptionId: subscription.id,
|
|
3022
|
-
requestedBy:
|
|
2988
|
+
requestedBy: c.get('user')?.did,
|
|
3023
2989
|
});
|
|
3024
|
-
return
|
|
2990
|
+
return c.json({
|
|
3025
2991
|
open: false,
|
|
3026
2992
|
overdraft_protection: subscription.overdraft_protection,
|
|
3027
2993
|
});
|
|
3028
2994
|
}
|
|
3029
2995
|
if (remaining !== '0' && used !== '0' && !enabled) {
|
|
3030
|
-
// slash stake
|
|
3031
2996
|
slashOverdraftProtectionQueue.push({
|
|
3032
2997
|
id: `slash-overdraft-protection-${subscription.id}`,
|
|
3033
2998
|
job: { subscriptionId: subscription.id },
|
|
3034
2999
|
});
|
|
3035
3000
|
logger.info('Slash overdraft protection stake scheduled', {
|
|
3036
3001
|
subscriptionId: subscription.id,
|
|
3037
|
-
requestedBy:
|
|
3002
|
+
requestedBy: c.get('user')?.did,
|
|
3038
3003
|
});
|
|
3039
3004
|
}
|
|
3040
3005
|
await subscription.update({
|
|
@@ -3045,47 +3010,46 @@ router.post('/:id/overdraft-protection', authPortal, async (req, res) => {
|
|
|
3045
3010
|
},
|
|
3046
3011
|
});
|
|
3047
3012
|
if (enabled && Number(amount) > 0) {
|
|
3048
|
-
return
|
|
3013
|
+
return c.json({
|
|
3049
3014
|
open: true,
|
|
3050
3015
|
amount,
|
|
3051
3016
|
overdraft_protection: subscription.overdraft_protection,
|
|
3052
3017
|
});
|
|
3053
3018
|
}
|
|
3054
3019
|
if (enabled) {
|
|
3055
|
-
// release the exhausted lock, so that the notification can be sent again if overdraft protection exhausted
|
|
3056
3020
|
await Lock.release(`${subscription.id}-${subscription.currency_id}-overdraft-protection-exhausted`);
|
|
3057
3021
|
}
|
|
3058
|
-
return
|
|
3022
|
+
return c.json({
|
|
3059
3023
|
open: false,
|
|
3060
3024
|
overdraft_protection: subscription.overdraft_protection,
|
|
3061
3025
|
});
|
|
3062
|
-
} catch (err) {
|
|
3026
|
+
} catch (err: any) {
|
|
3063
3027
|
logger.error(err);
|
|
3064
|
-
return
|
|
3028
|
+
return c.json({ error: err.message }, 400);
|
|
3065
3029
|
}
|
|
3066
3030
|
});
|
|
3067
3031
|
|
|
3068
|
-
|
|
3069
|
-
const subscription = await Subscription.findByPk(req.
|
|
3032
|
+
app.get('/:id/unpaid-invoices', authPortal, async (c) => {
|
|
3033
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
3070
3034
|
if (!subscription) {
|
|
3071
|
-
return
|
|
3035
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
3072
3036
|
}
|
|
3073
3037
|
const count = await getSubscriptionUnpaidInvoicesCount(subscription);
|
|
3074
|
-
return
|
|
3038
|
+
return c.json({ count });
|
|
3075
3039
|
});
|
|
3076
3040
|
|
|
3077
|
-
|
|
3078
|
-
const subscription = await Subscription.findByPk(req.
|
|
3041
|
+
app.get('/:id/change-payment/migrate-invoice', auth, async (c) => {
|
|
3042
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
3079
3043
|
if (!subscription) {
|
|
3080
|
-
return
|
|
3044
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
3081
3045
|
}
|
|
3082
3046
|
const context = subscription.metadata.changePayment || {};
|
|
3083
3047
|
if (!context.setup_intent_id) {
|
|
3084
|
-
return
|
|
3048
|
+
return c.json({ error: 'Subscription change payment context not found' }, 404);
|
|
3085
3049
|
}
|
|
3086
3050
|
const setupIntent = await SetupIntent.findByPk(context.setup_intent_id);
|
|
3087
3051
|
if (!setupIntent) {
|
|
3088
|
-
return
|
|
3052
|
+
return c.json({ error: 'Setup intent not found' }, 404);
|
|
3089
3053
|
}
|
|
3090
3054
|
try {
|
|
3091
3055
|
const migrationResult = await migrateSubscriptionPaymentMethodInvoice(
|
|
@@ -3093,37 +3057,37 @@ router.get('/:id/change-payment/migrate-invoice', auth, async (req, res) => {
|
|
|
3093
3057
|
setupIntent.metadata?.from_currency,
|
|
3094
3058
|
setupIntent.metadata?.to_currency
|
|
3095
3059
|
);
|
|
3096
|
-
return
|
|
3097
|
-
} catch (error) {
|
|
3060
|
+
return c.json(migrationResult);
|
|
3061
|
+
} catch (error: any) {
|
|
3098
3062
|
logger.error(error);
|
|
3099
|
-
return
|
|
3063
|
+
return c.json({ error: error.message }, 400);
|
|
3100
3064
|
}
|
|
3101
3065
|
});
|
|
3102
3066
|
|
|
3103
|
-
|
|
3067
|
+
app.post('/:id/update-stripe-payment-method', authPortal, async (c) => {
|
|
3104
3068
|
try {
|
|
3105
|
-
const subscription = await Subscription.findByPk(req.
|
|
3069
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
3106
3070
|
if (!subscription) {
|
|
3107
|
-
return
|
|
3071
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
3108
3072
|
}
|
|
3109
3073
|
|
|
3110
3074
|
if (!['active', 'trialing', 'past_due'].includes(subscription.status)) {
|
|
3111
|
-
return
|
|
3075
|
+
return c.json({ error: 'Subscription is not active' }, 400);
|
|
3112
3076
|
}
|
|
3113
3077
|
|
|
3114
3078
|
const paymentMethod = await PaymentMethod.findByPk(subscription.default_payment_method_id);
|
|
3115
3079
|
if (!paymentMethod || paymentMethod.type !== 'stripe') {
|
|
3116
|
-
return
|
|
3080
|
+
return c.json({ error: 'Subscription is not using Stripe payment method' }, 400);
|
|
3117
3081
|
}
|
|
3118
3082
|
|
|
3119
3083
|
const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id;
|
|
3120
3084
|
if (!stripeSubscriptionId) {
|
|
3121
|
-
return
|
|
3085
|
+
return c.json({ error: 'Stripe subscription not found' }, 400);
|
|
3122
3086
|
}
|
|
3123
3087
|
|
|
3124
3088
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
3125
3089
|
if (!customer) {
|
|
3126
|
-
return
|
|
3090
|
+
return c.json({ error: 'Customer not found' }, 404);
|
|
3127
3091
|
}
|
|
3128
3092
|
|
|
3129
3093
|
await ensureStripeCustomer(customer, paymentMethod);
|
|
@@ -3146,63 +3110,51 @@ router.post('/:id/update-stripe-payment-method', authPortal, async (req, res) =>
|
|
|
3146
3110
|
setupIntent: setupIntent.id,
|
|
3147
3111
|
});
|
|
3148
3112
|
|
|
3149
|
-
return
|
|
3113
|
+
return c.json({
|
|
3150
3114
|
client_secret: setupIntent.client_secret,
|
|
3151
3115
|
publishable_key: settings.stripe?.publishable_key,
|
|
3152
3116
|
setup_intent_id: setupIntent.id,
|
|
3153
3117
|
});
|
|
3154
|
-
} catch (err) {
|
|
3118
|
+
} catch (err: any) {
|
|
3155
3119
|
logger.error('Failed to create setup intent for updating payment method', {
|
|
3156
3120
|
error: err,
|
|
3157
|
-
subscriptionId: req.
|
|
3121
|
+
subscriptionId: c.req.param('id'),
|
|
3158
3122
|
});
|
|
3159
|
-
return
|
|
3123
|
+
return c.json({ error: err.message }, 400);
|
|
3160
3124
|
}
|
|
3161
3125
|
});
|
|
3162
3126
|
|
|
3163
3127
|
/**
|
|
3164
3128
|
* Fix subscription after payment method migration from Stripe to non-Stripe
|
|
3165
|
-
* This API is used to fix legacy subscriptions that were migrated but the migration
|
|
3166
|
-
* was incomplete (missing payment_settings/payment_details updates)
|
|
3167
|
-
*
|
|
3168
|
-
* This API will:
|
|
3169
|
-
* 1. Pause the Stripe subscription (if not already paused)
|
|
3170
|
-
* 2. Update payment_settings to use arcblock
|
|
3171
|
-
* 3. Update payment_details to add arcblock info
|
|
3172
|
-
* 4. Recalculate cancel_at if needed
|
|
3173
3129
|
*/
|
|
3174
|
-
|
|
3130
|
+
app.put('/:id/fix-stripe-migration', auth, async (c) => {
|
|
3175
3131
|
try {
|
|
3176
|
-
const subscription = await Subscription.findByPk(req.
|
|
3132
|
+
const subscription = await Subscription.findByPk(c.req.param('id'));
|
|
3177
3133
|
if (!subscription) {
|
|
3178
|
-
return
|
|
3134
|
+
return c.json({ error: 'Subscription not found' }, 404);
|
|
3179
3135
|
}
|
|
3180
3136
|
|
|
3181
3137
|
const stripeSubscriptionId = subscription.payment_details?.stripe?.subscription_id;
|
|
3182
3138
|
if (!stripeSubscriptionId) {
|
|
3183
|
-
return
|
|
3139
|
+
return c.json({ error: 'Subscription does not have Stripe subscription_id' }, 400);
|
|
3184
3140
|
}
|
|
3185
3141
|
|
|
3186
|
-
// Get customer for payer DID
|
|
3187
3142
|
const customer = await Customer.findByPk(subscription.customer_id);
|
|
3188
3143
|
if (!customer) {
|
|
3189
|
-
return
|
|
3144
|
+
return c.json({ error: 'Customer not found' }, 404);
|
|
3190
3145
|
}
|
|
3191
3146
|
|
|
3192
|
-
// Find arcblock payment method
|
|
3193
3147
|
const arcblockMethod = await PaymentMethod.findOne({
|
|
3194
3148
|
where: { type: 'arcblock', livemode: subscription.livemode },
|
|
3195
3149
|
});
|
|
3196
3150
|
if (!arcblockMethod) {
|
|
3197
|
-
return
|
|
3151
|
+
return c.json({ error: 'ArcBlock payment method not found' }, 400);
|
|
3198
3152
|
}
|
|
3199
3153
|
|
|
3200
|
-
// Find Stripe payment method to pause subscription
|
|
3201
3154
|
const stripeMethod = await PaymentMethod.findOne({
|
|
3202
3155
|
where: { type: 'stripe', livemode: subscription.livemode },
|
|
3203
3156
|
});
|
|
3204
3157
|
|
|
3205
|
-
// 1. Pause Stripe subscription if not already paused
|
|
3206
3158
|
let stripePaused = false;
|
|
3207
3159
|
if (stripeMethod) {
|
|
3208
3160
|
try {
|
|
@@ -3232,9 +3184,9 @@ router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
|
3232
3184
|
}
|
|
3233
3185
|
}
|
|
3234
3186
|
|
|
3187
|
+
const body = c.get('sanitizedBody') ?? {};
|
|
3235
3188
|
const updates: Partial<TSubscription> = {};
|
|
3236
3189
|
|
|
3237
|
-
// 2. Update payment_settings to use arcblock (matching change-payment behavior)
|
|
3238
3190
|
updates.payment_settings = {
|
|
3239
3191
|
payment_method_types: ['arcblock'],
|
|
3240
3192
|
payment_method_options: {
|
|
@@ -3242,7 +3194,6 @@ router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
|
3242
3194
|
},
|
|
3243
3195
|
};
|
|
3244
3196
|
|
|
3245
|
-
// 3. Update payment_details.arcblock.payer if not already present
|
|
3246
3197
|
if (!subscription.payment_details?.arcblock?.payer) {
|
|
3247
3198
|
const existingArcblock = subscription.payment_details?.arcblock;
|
|
3248
3199
|
updates.payment_details = {
|
|
@@ -3256,15 +3207,13 @@ router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
|
3256
3207
|
};
|
|
3257
3208
|
}
|
|
3258
3209
|
|
|
3259
|
-
// 4. Update default_payment_method_id to arcblock
|
|
3260
3210
|
updates.default_payment_method_id = arcblockMethod.id;
|
|
3261
3211
|
|
|
3262
|
-
|
|
3263
|
-
if (req.body.recalculate_cancel_at !== false) {
|
|
3212
|
+
if (body.recalculate_cancel_at !== false) {
|
|
3264
3213
|
if (subscription.cancelation_details && !subscription.cancel_at) {
|
|
3265
3214
|
const daysUntilCancel = subscription.days_until_cancel || 0;
|
|
3266
3215
|
if (daysUntilCancel > 0) {
|
|
3267
|
-
const dueUnit = 24 * 60 * 60;
|
|
3216
|
+
const dueUnit = 24 * 60 * 60;
|
|
3268
3217
|
updates.cancel_at = subscription.current_period_start + daysUntilCancel * dueUnit;
|
|
3269
3218
|
} else {
|
|
3270
3219
|
updates.cancel_at_period_end = true;
|
|
@@ -3273,9 +3222,8 @@ router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
|
3273
3222
|
}
|
|
3274
3223
|
}
|
|
3275
3224
|
|
|
3276
|
-
|
|
3277
|
-
|
|
3278
|
-
updates.cancel_at = req.body.cancel_at;
|
|
3225
|
+
if (typeof body.cancel_at === 'number') {
|
|
3226
|
+
updates.cancel_at = body.cancel_at;
|
|
3279
3227
|
}
|
|
3280
3228
|
|
|
3281
3229
|
await subscription.update(updates);
|
|
@@ -3291,10 +3239,9 @@ router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
|
3291
3239
|
stripeSubscriptionId,
|
|
3292
3240
|
});
|
|
3293
3241
|
|
|
3294
|
-
// Reload subscription to return updated data
|
|
3295
3242
|
await subscription.reload();
|
|
3296
3243
|
|
|
3297
|
-
return
|
|
3244
|
+
return c.json({
|
|
3298
3245
|
success: true,
|
|
3299
3246
|
stripePaused,
|
|
3300
3247
|
subscription: pick(subscription, [
|
|
@@ -3312,13 +3259,13 @@ router.put('/:id/fix-stripe-migration', auth, async (req, res) => {
|
|
|
3312
3259
|
'current_period_end',
|
|
3313
3260
|
]),
|
|
3314
3261
|
});
|
|
3315
|
-
} catch (err) {
|
|
3262
|
+
} catch (err: any) {
|
|
3316
3263
|
logger.error('Failed to fix subscription stripe migration', {
|
|
3317
3264
|
error: err,
|
|
3318
|
-
subscriptionId: req.
|
|
3265
|
+
subscriptionId: c.req.param('id'),
|
|
3319
3266
|
});
|
|
3320
|
-
return
|
|
3267
|
+
return c.json({ error: err.message }, 400);
|
|
3321
3268
|
}
|
|
3322
3269
|
});
|
|
3323
3270
|
|
|
3324
|
-
export default
|
|
3271
|
+
export default app;
|