mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Add Stripe migration features for stage 7 testing (part 2) (#35890)
* upsert: migrate US/CA PayPal without a payment method when flagged getPaymentMethod no longer throws for a PayPal billing agreement destined for Stripe US/CA when the account is flagged ignore_payment_method; instead it returns null so the customer is migrated without a default payment method (the send_invoice paymentMethodPending flow). Unflagged accounts still throw. * finalize: expect the plan-code remap for legacy-plan subscriptions When a subscription carries legacy_remap=1 metadata, compare the Stripe product's planCode against the recorded target plan code instead of the raw Recurly plan code, so the intentional remap is not misread as drift (the price is preserved, so the amount comparison is unchanged). Clear the legacy_remap metadata during post-migration cleanup. * finalize: expect the assistant add-on reprice When a subscription carries assist_addon_repriced=1 metadata, compare the assistant item against the recorded target amount (Stripe minor units) instead of the raw Recurly amount, so the intended reprice is not misread as drift. Clear the reprice metadata during post-migration cleanup. * create_coupons: create migration coupons by name with amount_off, currency and product scope Create the pipeline's per-region coupon_specs_<region>.csv coupons by name (idempotent, auto-paginated), supporting amount_off + currency (fixed) or percent_off, duration, and applies_to.products, with no promotion code. Region comes from the existing --region flag. The existing name-based percent-off + promotion-code path is unchanged. * finalize: schedule the pending change as a phase 2 For a subscription flagged pending_change_scheduled, compare drift against the current Recurly state (not the pending change), then in Step 7 release the toolkit schedule and create a fresh two-phase subscription schedule (current until renewal; pending state from renewal with proration disabled and end_behavior release), clearing the scheduling metadata once the schedule exists. * finalize: flag address-pending migrations and push the needs_to_update_address trait When a migrated subscription carries metadata.addressPending, append -address-pending to the finalization status and push needs_to_update_address to customer.io (alongside the existing tax-info-pending handling), so the customer can be driven to add a billing address. The flag stays on the subscription so the customer.updated webhook can enable automatic_tax and clear it once the address resolves. * webhook: heal address-pending migrations when the tax location resolves On customer.updated (address/tax change), for a customer whose location is no longer unrecognized_location, enable automatic_tax on their migrated subscriptions still flagged metadata.addressPending and clear the flag. Enabling automatic_tax is rejected by Stripe while the location is still invalid, so the update is a safe idempotent no-op until the address is fixed; the cancellation state is never touched. * cron: cancel unresolved address-pending subscriptions before they renew untaxed Backstop for the address-pending cohort: searches the address-pending subscriptions in a region and, for any about to renew (within a configurable lead window) whose customer location is still unrecognized_location, cancels it at period end so it never renews untaxed. Skips subscriptions that are already cancelling and those whose location has resolved (healed by the webhook). Dry-run unless --commit. * upsert: set tax.validate_location=auto on customer update Conform PAN-copied customers to the account's default tax-location validation. Uses 'auto' rather than 'immediately' (which throws for country-only addresses and would break the address-pending migration flow). * finalize-migration: send payment method info to customer.io Add payment_method (card/paypal/none) and needs_to_add_payment_method traits to the stripe_migration customer.io payload so the migration email campaign can personalise per payment method without enumerating the full cross-join. The payment method is read from the already-fetched customer/subscription (default_payment_method now expanded on that fetch), so no extra Stripe request is made. Also report a -payment-method-pending status suffix in the CSV output. * upsert: flag customers whose address Stripe cannot resolve After pushing the Recurly address, read customer.tax.automatic_tax; when it is 'unrecognized_location', record address_unrecognized in the output so the subscription is migrated untaxed (address-pending) instead of failing at the billing-migration toolkit. This is the authoritative, region-agnostic detector (covers US/CA country-only and broken non-US addresses, e.g. India with a postal but no state/city). Adds isCustomerAddressUnrecognized helper + unit tests. * Close reactivation-without-valid-address loophole * webhook: detect newly-set address/tax with !== undefined Review follow-up: the address-pending heal guard used a truthy check, which would miss an address newly set from empty. Harmonise with _billingChanges, which already uses !== undefined. * upsert: set tax.validate_location via customerParams Review follow-up: move the tax.validate_location='auto' directive into the assembled customerParams instead of overriding it inline at the update call, so it also appears in the dry-run output. * create_coupons: guard migration coupons against ambiguous discount specs Review follow-up: throw a clear per-coupon error if a migration coupon spec has neither or both of amount_off/percent_off, catching data-processing issues instead of silently sending percent_off=NaN. * Rely on presence of customer.tax * webhook: cover the address-pending heal path in unit tests The customer.updated fixtures carried no `tax` hash and the mocked region client had no `stripe` property, so the one test that reached the heal branch threw and was swallowed by the caller's error handler. The heal logic had no real coverage as a result. Add the missing fixture fields and tests covering the heal itself, the still-unrecognized early return, the skip rules for unflagged and cancelled subscriptions, the Stripe rejection path that leaves the flag set, and the missing-tax guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> GitOrigin-RevId: 8162e3e0f01e136d3aa560c22e98f43e36da4eb5
This commit is contained in:
committed by
Copybot
co-authored by
Claude Opus 5
parent
446c4f3f6a
commit
1ecdd122d0
@@ -36,6 +36,8 @@ export class HasNoAdditionalLicenseWhenManuallyCollectedError extends OError {}
|
||||
|
||||
export class InvalidTaxIdError extends OError {}
|
||||
|
||||
export class AddressPendingReactivationError extends OError {}
|
||||
|
||||
export class StripeClientIdempotencyKeyInUseError extends OError {
|
||||
constructor() {
|
||||
super('Stripe idempotency key was already in use')
|
||||
@@ -85,5 +87,6 @@ export default {
|
||||
HasPastDueInvoiceError,
|
||||
HasNoAdditionalLicenseWhenManuallyCollectedError,
|
||||
InvalidTaxIdError,
|
||||
AddressPendingReactivationError,
|
||||
StripeClientIdempotencyKeyInUseError,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { z, zz, parseReq } from '../../infrastructure/Validation.mjs'
|
||||
import { PaymentProviderSubscriptionChange } from './PaymentProviderEntities.mjs'
|
||||
|
||||
const { AddOnNotPresentError, MultiplePendingChangesError } = Errors
|
||||
const { AddressPendingReactivationError } = Errors
|
||||
|
||||
const SUBSCRIPTION_PAUSED_REDIRECT_PATH =
|
||||
'/user/subscription?redirect-reason=subscription-paused'
|
||||
@@ -827,6 +828,13 @@ function reactivateSubscription(req, res, next) {
|
||||
}
|
||||
SubscriptionHandler.reactivateSubscription(user, function (err) {
|
||||
if (err) {
|
||||
if (err instanceof AddressPendingReactivationError) {
|
||||
return res.status(422).json({
|
||||
code: 'address_pending',
|
||||
message:
|
||||
'Please add a valid billing address to your account before reactivating your subscription.',
|
||||
})
|
||||
}
|
||||
OError.tag(err, 'something went wrong reactivating subscription', {
|
||||
user_id: user._id,
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import LimitationsManager from './LimitationsManager.mjs'
|
||||
import EmailHandler from '../Email/EmailHandler.mjs'
|
||||
import { callbackify } from '@overleaf/promise-utils'
|
||||
import Modules from '../../infrastructure/Modules.mjs'
|
||||
import SubscriptionErrors from './Errors.mjs'
|
||||
import { AI_ADD_ON_CODE } from './AiHelper.mjs'
|
||||
import CustomerIoPlanHelpers from './CustomerIoPlanHelpers.mjs'
|
||||
import WorkbenchRateLimiter from '../../infrastructure/rate-limiters/WorkbenchRateLimiter.mjs'
|
||||
@@ -197,6 +198,9 @@ async function reactivateSubscription(user) {
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof SubscriptionErrors.AddressPendingReactivationError) {
|
||||
throw err
|
||||
}
|
||||
logger.warn(
|
||||
{ err, userId: user._id },
|
||||
'there was an error checking user v2 subscription'
|
||||
|
||||
@@ -1620,6 +1620,7 @@
|
||||
"reactivate": "",
|
||||
"reactivate_add_on_failed": "",
|
||||
"reactivate_subscription": "",
|
||||
"reactivate_subscription_valid_address_required": "",
|
||||
"reactivating": "",
|
||||
"read_lines_from_path": "",
|
||||
"read_more": "",
|
||||
|
||||
+45
-13
@@ -1,15 +1,23 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import { reactivateSubscriptionUrl } from '../../data/subscription-url'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { FetchError, postJSON } from '../../../../infrastructure/fetch-json'
|
||||
import {
|
||||
reactivateSubscriptionUrl,
|
||||
billingPortalUrl,
|
||||
} from '../../data/subscription-url'
|
||||
import useAsync from '../../../../shared/hooks/use-async'
|
||||
import { useLocation } from '../../../../shared/hooks/use-location'
|
||||
import getMeta from '../../../../utils/meta'
|
||||
import { debugConsole } from '@/utils/debugging'
|
||||
import OLButton from '@/shared/components/ol/ol-button'
|
||||
import Notification from '@/shared/components/notification'
|
||||
import GenericErrorAlert from './generic-error-alert'
|
||||
|
||||
function ReactivateSubscription() {
|
||||
const { t } = useTranslation()
|
||||
const { isLoading, isSuccess, runAsync } = useAsync()
|
||||
const { isLoading, isSuccess, isError, error, runAsync } = useAsync<
|
||||
void,
|
||||
FetchError
|
||||
>()
|
||||
const location = useLocation()
|
||||
|
||||
const handleReactivate = () => {
|
||||
@@ -29,16 +37,40 @@ function ReactivateSubscription() {
|
||||
return null
|
||||
}
|
||||
|
||||
const isAddressPendingError = error?.data?.code === 'address_pending'
|
||||
|
||||
return (
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={isLoading || isSuccess}
|
||||
onClick={handleReactivate}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('reactivating')}
|
||||
>
|
||||
{t('reactivate_subscription')}
|
||||
</OLButton>
|
||||
<>
|
||||
{isError &&
|
||||
(isAddressPendingError ? (
|
||||
<div className="notification-list">
|
||||
<Notification
|
||||
aria-live="polite"
|
||||
type="error"
|
||||
content={
|
||||
<Trans
|
||||
i18nKey="reactivate_subscription_valid_address_required"
|
||||
components={[
|
||||
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
|
||||
<a href={billingPortalUrl} />,
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<GenericErrorAlert />
|
||||
))}
|
||||
<OLButton
|
||||
variant="primary"
|
||||
disabled={isLoading || isSuccess}
|
||||
onClick={handleReactivate}
|
||||
isLoading={isLoading}
|
||||
loadingLabel={t('reactivating')}
|
||||
>
|
||||
{t('reactivate_subscription')}
|
||||
</OLButton>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -2092,6 +2092,7 @@
|
||||
"reactivate": "Reactivate",
|
||||
"reactivate_add_on_failed": "Something went wrong while reactivating your add-on. Please try again later.",
|
||||
"reactivate_subscription": "Reactivate your subscription",
|
||||
"reactivate_subscription_valid_address_required": "Please <0>add a valid billing address to your account</0> before reactivating your subscription.",
|
||||
"reactivating": "Reactivating",
|
||||
"read_lines_from_path": "Read lines from __path__",
|
||||
"read_more": "Read more",
|
||||
|
||||
@@ -756,6 +756,39 @@ describe('SubscriptionController', function () {
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the subscription cannot be reactivated due to a pending address', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
ctx.json = sinon.stub().callsFake(() => resolve())
|
||||
ctx.res = {
|
||||
status: sinon.stub().returns({ json: ctx.json }),
|
||||
}
|
||||
ctx.req.assertPermission = sinon.stub()
|
||||
ctx.next = sinon.stub().callsFake(error => resolve(error))
|
||||
ctx.SubscriptionHandler.reactivateSubscription = sinon
|
||||
.stub()
|
||||
.callsArgWith(
|
||||
1,
|
||||
new SubscriptionErrors.AddressPendingReactivationError()
|
||||
)
|
||||
ctx.SubscriptionController.reactivateSubscription(
|
||||
ctx.req,
|
||||
ctx.res,
|
||||
ctx.next
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond with a 422 and an address_pending code', async function (ctx) {
|
||||
ctx.res.status.calledWith(422).should.equal(true)
|
||||
ctx.json.firstCall.args[0].should.have.property(
|
||||
'code',
|
||||
'address_pending'
|
||||
)
|
||||
ctx.next.called.should.equal(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('when the user does not have permission', function () {
|
||||
beforeEach(async function (ctx) {
|
||||
await new Promise(resolve => {
|
||||
|
||||
@@ -134,6 +134,7 @@ export interface CustomerUpdatedWebhookEvent extends Stripe.EventBase {
|
||||
address?: Stripe.Address
|
||||
name?: string
|
||||
email?: string
|
||||
tax?: Stripe.Customer.Tax
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user