Skip to content

Commit 1465ff5

Browse files
committed
fix(subscription): a recovered key from a cancelled order is offered, not installed
An abandoned order settling after the buyer had already bought again applied its key over the newer one, silently moving the wallet onto the older purchase. When a newer order exists, the recovered key is now announced rather than adopted, and its record is kept so the buyer can choose it from Settings.
1 parent f7d0420 commit 1465ff5

2 files changed

Lines changed: 39 additions & 3 deletions

File tree

src/components/upgrade/PlanScreen.tsx

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -581,7 +581,7 @@ export function PlanScreen({ isOpen, reason, onboarding, onClose }: PlanScreenPr
581581
* and act only on a verdict. A late-confirming payment still hands over its
582582
* key; an order that never landed stays quiet.
583583
*/
584-
const settleQuietly = async (record: PendingOrderRecord) => {
584+
const settleQuietly = async (record: PendingOrderRecord, superseded: boolean) => {
585585
if (rootPubkey === null) return;
586586
try {
587587
const verdict = settleOrder(await getOrderStatus(record.orderId));
@@ -617,6 +617,17 @@ export function PlanScreen({ isOpen, reason, onboarding, onClose }: PlanScreenPr
617617
if (action.kind === 'adopt') {
618618
// Its own address will settle it; adopting here files the key wrong.
619619
if (!canAdoptFor(record)) return;
620+
// A newer order has taken over since this one was abandoned. Applying
621+
// this key would silently swap the wallet onto the older purchase, so
622+
// it is offered rather than installed.
623+
if (superseded) {
624+
showToast(
625+
`An earlier order you cancelled has been paid and holds a key for ${record.plan.name}. Open Settings → Subscription to use it instead of your current one.`,
626+
'info',
627+
8000,
628+
);
629+
return;
630+
}
620631
if (claimPendingOrder(network, rootPubkey, record.orderId) === 'held') return;
621632
const { durable } = await applySubscriptionKey(action.apiKey, { walletWide: record.walletWide });
622633
await queryClient.invalidateQueries({ queryKey: SPHERE_KEYS.subscription.all });
@@ -845,8 +856,13 @@ export function PlanScreen({ isOpen, reason, onboarding, onClose }: PlanScreenPr
845856
// Serialized: several settling at once would race each other's storage
846857
// read-modify-writes and toasts.
847858
void (async () => {
848-
for (const stale of readSettlableOrders(network, rootPubkey)) {
849-
if (stale.abandonedAt !== undefined) await settleQuietly(stale);
859+
const all = readSettlableOrders(network, rootPubkey);
860+
for (const stale of all) {
861+
if (stale.abandonedAt === undefined) continue;
862+
// "Superseded" = some other order for this wallet is newer. Its key, not
863+
// this one's, is the purchase the buyer is actually on.
864+
const superseded = all.some((other) => other.orderId !== stale.orderId && other.createdAt > stale.createdAt);
865+
await settleQuietly(stale, superseded);
850866
}
851867
})();
852868
const record = readPendingOrder(network, rootPubkey);

tests/unit/components/planScreenOrderResume.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,3 +674,23 @@ describe('orders that exist server-side before the wallet knows it', () => {
674674
await waitFor(() => expect(readSettlableOrders('mainnet', ROOT_PUBKEY)).toHaveLength(1));
675675
});
676676
});
677+
678+
describe('a late-settling abandoned order never demotes a newer purchase', () => {
679+
it('offers the recovered key instead of installing it over the current one', async () => {
680+
const old = pendingOrder({ orderId: 'ssc-old', upgradeMasked: null, createdAt: Date.now() - 60_000, abandonedAt: Date.now() - 30_000 });
681+
const current = pendingOrder({ orderId: 'ssc-new', upgradeMasked: null, createdAt: Date.now() });
682+
savePendingOrder('mainnet', ROOT_PUBKEY, old);
683+
savePendingOrder('mainnet', ROOT_PUBKEY, current);
684+
h.orderStatus = vi.fn(async (id: string) =>
685+
id === 'ssc-old' ? paid({ apiKey: 'sk_' + 'a'.repeat(32) }) : stillOpen(),
686+
);
687+
h.poll = vi.fn(async () => new Promise(() => {}));
688+
renderDialog();
689+
690+
await waitFor(() => expect(h.orderStatus).toHaveBeenCalledWith('ssc-old'));
691+
// The older key is never applied over the order the buyer is actually on.
692+
expect(h.applySubscriptionKey).not.toHaveBeenCalled();
693+
expect(h.ack).not.toHaveBeenCalled();
694+
expect(readSettlableOrders('mainnet', ROOT_PUBKEY).some((r) => r.orderId === 'ssc-old')).toBe(true);
695+
});
696+
});

0 commit comments

Comments
 (0)