@billfn/core 0.2.0 → 0.4.0

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/dist/service.js CHANGED
@@ -1,7 +1,9 @@
1
+ import { createHash } from 'node:crypto';
1
2
  import { DuplicateKeyError } from '@superfunctions/db';
2
3
  import { ok } from '@superfunctions/envelope';
3
4
  import { createNamespacedEmitter, createMetricsEmitter } from '@superfunctions/metrics';
4
5
  import { createBillFnError } from './errors.js';
6
+ import { applyGrants, insertGrant, listRepairedGrants, readCreateGrantInput, repairGrantEvents, requireFutureExpiry, revokeGrant, toGrantSummary } from './grants.js';
5
7
  import { createDefaultBillingAccountResolver, defaultIdFactory, resolvePlan, resolvePrice, toIsoString } from './helpers.js';
6
8
  const TABLES = {
7
9
  billingAccounts: 'billingAccounts',
@@ -33,6 +35,23 @@ const UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS = 15 * 60 * 1000;
33
35
  const UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS = 500;
34
36
  /** Provider metadata key naming the billing account that owns a provider record. */
35
37
  const BILLING_ACCOUNT_METADATA_KEY = 'billfn_billing_account_id';
38
+ /**
39
+ * Subscription metadata key set when an operator imported the subscription:
40
+ * when it was first imported. Its binding to the billing account is the
41
+ * operator's, so a provider adapter does not expect the BillFn ownership
42
+ * metadata a subscription created outside BillFn lacks.
43
+ */
44
+ export const BILLFN_IMPORTED_AT_METADATA_KEY = 'billfnImportedAt';
45
+ /**
46
+ * Subscription metadata key set on the row an import creates until its
47
+ * binding is accepted (`settleImportBinding`). A pending row is not usable:
48
+ * webhooks and replays leave events for it unlinked, other writes refuse it
49
+ * (retryably) and subject, account and sync lookups skip it. A refusal deletes
50
+ * it, on the import's first run or on any rerun, so it never becomes durable.
51
+ */
52
+ export const BILLFN_IMPORT_PENDING_METADATA_KEY = 'billfnImportPending';
53
+ /** `details.reason` of the retryable `BILLFN_CONFLICT` an import raises when its replay did not finish. */
54
+ export const BILLFN_IMPORT_REPLAY_UNFINISHED_REASON = 'import_replay_unfinished';
36
55
  /** Deliveries of one queue message refused by lease conflicts before it is dead-lettered. */
37
56
  const RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS = 5;
38
57
  /**
@@ -78,6 +97,34 @@ function createBillFnServiceWithInternals(config) {
78
97
  queue: config.queue
79
98
  };
80
99
  deps.reconcileProviderState = (subscription, event) => reconcileWindowEvent(subscription, event);
100
+ const grantDeps = {
101
+ ...deps,
102
+ recordBillingEvent: (event, db) => recordBillingEvent(db ? { ...deps, db } : deps, event),
103
+ isDuplicateRecordError
104
+ };
105
+ /**
106
+ * The account's entitlements: its paid snapshot, or an active grant while that snapshot is `inactive`, and the
107
+ * grants they were derived from. The grants' missing events are repaired on every read (see
108
+ * `repairGrantEvents`). While the paid snapshot decides and the grants are not asked for, that repair is all
109
+ * that reads them, and it never fails the read; otherwise their status is derived after the paid snapshot read
110
+ * and the repair (see `listRepairedGrants`).
111
+ */
112
+ async function readEntitlements(billingAccountId, withGrantList = false) {
113
+ const paid = await findEntitlementSnapshot(deps, billingAccountId);
114
+ if (!withGrantList && paid && paid.status !== 'inactive') {
115
+ await repairGrantEvents(grantDeps, billingAccountId);
116
+ return { entitlements: paid, grants: [] };
117
+ }
118
+ const grants = await listRepairedGrants(grantDeps, billingAccountId);
119
+ return { entitlements: await applyGrants(grantDeps, billingAccountId, paid, grants), grants };
120
+ }
121
+ async function findEffectiveEntitlements(billingAccountId) {
122
+ return (await readEntitlements(billingAccountId)).entitlements;
123
+ }
124
+ /** A write's paid snapshot as the account's entitlements (see `applyGrants`). */
125
+ async function withGrants(billingAccountId, paid) {
126
+ return (await applyGrants(grantDeps, billingAccountId, paid)) ?? paid;
127
+ }
81
128
  const subscriptionProvider = {
82
129
  getActiveSubscription: async (subject) => {
83
130
  const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
@@ -91,7 +138,7 @@ function createBillFnServiceWithInternals(config) {
91
138
  if (!billingAccount) {
92
139
  return null;
93
140
  }
94
- return findEntitlementSnapshot(deps, billingAccount.id);
141
+ return findEffectiveEntitlements(billingAccount.id);
95
142
  },
96
143
  isActive: async (subject) => {
97
144
  const snapshot = await subscriptionProvider.getEntitlementSnapshot(subject);
@@ -112,8 +159,10 @@ function createBillFnServiceWithInternals(config) {
112
159
  tenantId: input.tenantId
113
160
  };
114
161
  const resource = input.resource ?? 'storage';
115
- const entitlements = await subscriptionProvider.getEntitlementSnapshot(subject);
116
- if (!entitlements || entitlements.status === 'inactive') {
162
+ // Status and limit come from one entitlements read, so a grant ending between reads cannot mix them.
163
+ const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
164
+ const entitlements = billingAccount ? await findEffectiveEntitlements(billingAccount.id) : null;
165
+ if (!billingAccount || !entitlements || entitlements.status === 'inactive') {
117
166
  return {
118
167
  allowed: false,
119
168
  current: 0,
@@ -121,19 +170,20 @@ function createBillFnServiceWithInternals(config) {
121
170
  reason: 'subscription_inactive'
122
171
  };
123
172
  }
124
- const usage = await quotaProvider.getUsage(input.principalId, input.tenantId, resource);
125
- const limit = usage.limit;
126
- if (limit >= 0 && usage.current + input.requestedBytes > limit) {
173
+ const meter = await findUsageMeter(deps, billingAccount.id, resource);
174
+ const current = meter?.current ?? 0;
175
+ const limit = entitlements.limits[resource] ?? 0;
176
+ if (limit >= 0 && current + input.requestedBytes > limit) {
127
177
  return {
128
178
  allowed: false,
129
- current: usage.current,
179
+ current,
130
180
  limit,
131
181
  reason: 'quota_exceeded'
132
182
  };
133
183
  }
134
184
  return {
135
185
  allowed: true,
136
- current: usage.current,
186
+ current,
137
187
  limit
138
188
  };
139
189
  },
@@ -152,7 +202,7 @@ function createBillFnServiceWithInternals(config) {
152
202
  };
153
203
  const billingAccount = await findBillingAccountForSubject(deps, requireSubject(subject));
154
204
  const meter = billingAccount ? await findUsageMeter(deps, billingAccount.id, resource) : null;
155
- const entitlements = billingAccount ? await findEntitlementSnapshot(deps, billingAccount.id) : null;
205
+ const entitlements = billingAccount ? await findEffectiveEntitlements(billingAccount.id) : null;
156
206
  return {
157
207
  current: meter?.current ?? 0,
158
208
  limit: entitlements?.limits[resource] ?? 0
@@ -315,7 +365,7 @@ function createBillFnServiceWithInternals(config) {
315
365
  });
316
366
  return {
317
367
  subscription: projection.subscription,
318
- entitlements: projection.entitlements,
368
+ entitlements: await withGrants(billingAccount.id, projection.entitlements),
319
369
  operationStatus: operation.operationStatus,
320
370
  clientAction: operation.clientAction
321
371
  };
@@ -595,7 +645,7 @@ function createBillFnServiceWithInternals(config) {
595
645
  return ok({
596
646
  checkoutSession: result.checkoutSession,
597
647
  subscription: result.subscription,
598
- entitlements: result.entitlements
648
+ entitlements: await withGrants(billingAccount.id, result.entitlements)
599
649
  });
600
650
  },
601
651
  async cancelSubscription(input) {
@@ -791,7 +841,7 @@ function createBillFnServiceWithInternals(config) {
791
841
  return ok({
792
842
  refund,
793
843
  subscription: projectedSubscription,
794
- entitlements: projectedEntitlements,
844
+ entitlements: await applyGrants(grantDeps, billingAccount.id, projectedEntitlements),
795
845
  operationStatus: operation.operationStatus,
796
846
  clientAction: operation.clientAction
797
847
  });
@@ -809,7 +859,7 @@ function createBillFnServiceWithInternals(config) {
809
859
  const applied = await syncSubscriptionById(subscription.id);
810
860
  const response = {
811
861
  subscription: applied.subscription,
812
- entitlements: applied.entitlements
862
+ entitlements: await withGrants(billingAccount.id, applied.entitlements)
813
863
  };
814
864
  return ok(response);
815
865
  },
@@ -869,7 +919,7 @@ function createBillFnServiceWithInternals(config) {
869
919
  });
870
920
  const response = {
871
921
  subscription: applied.subscription,
872
- entitlements: applied.entitlements
922
+ entitlements: await withGrants(billingAccount.id, applied.entitlements)
873
923
  };
874
924
  return ok(response);
875
925
  }
@@ -887,7 +937,7 @@ function createBillFnServiceWithInternals(config) {
887
937
  });
888
938
  const response = {
889
939
  subscription: applied.subscription,
890
- entitlements: applied.entitlements
940
+ entitlements: await withGrants(billingAccount.id, applied.entitlements)
891
941
  };
892
942
  return ok(response);
893
943
  }
@@ -896,6 +946,155 @@ function createBillFnServiceWithInternals(config) {
896
946
  message: 'No restored purchase could be linked to the resolved billing account'
897
947
  });
898
948
  },
949
+ async importSubscription(input) {
950
+ const { provider: providerName, reference, operator } = readImportRequest(input);
951
+ const plan = requirePlan(deps.catalog, input.planKey);
952
+ if (!plan.prices.some((price) => price.provider === providerName && price.kind === 'subscription')) {
953
+ throw createBillFnError({
954
+ code: 'BILLFN_VALIDATION_ERROR',
955
+ message: `Plan ${plan.planKey} has no ${providerName} subscription price`
956
+ });
957
+ }
958
+ const provider = requireProvider(deps, providerName, 'importSubscription');
959
+ // A reference replaced by a subscription write, or bound to another billing account, fails before the provider is read.
960
+ // A pending row (an import not yet accepted, perhaps interrupted) is checked like an unbound reference and
961
+ // removed when the reference is retired, so a rerun of a refused import converges to no binding.
962
+ const bound = await findSubscriptionByProviderRef(deps, providerName, reference);
963
+ if (!bound || isImportPending(bound)) {
964
+ await refuseRetiredImportReference(deps, providerName, reference, bound);
965
+ }
966
+ if (bound && bound.billingAccountId !== input.billingAccountId) {
967
+ throw importBoundElsewhere(providerName);
968
+ }
969
+ // Resolved after the checks above, so an import they refuse never creates the subject's billing account;
970
+ // one refused after the provider read keeps it.
971
+ const billingAccount = await resolveOperatorBillingAccount(deps, input);
972
+ // Events that occurred before this live read are already reflected in its state.
973
+ const importObservedAt = toIsoString(deps.now());
974
+ const state = await readImportState(provider, providerName, { providerReference: reference, billingAccount, plan });
975
+ if (!state) {
976
+ throw createBillFnError({
977
+ code: 'BILLFN_NOT_FOUND',
978
+ message: 'Provider did not return the subscription to import'
979
+ });
980
+ }
981
+ if (state.providerSubscriptionId !== reference) {
982
+ throw createBillFnError({
983
+ code: 'BILLFN_PROVIDER_ERROR',
984
+ message: 'The provider record does not match the imported reference'
985
+ });
986
+ }
987
+ const selection = requireImportPrice(plan, providerName, state);
988
+ // One audit event per import run and state it records, never changed once written. The run is the operator's
989
+ // request (a rerun of it that records a state it already recorded adds none, even after another state) or,
990
+ // without a requestId, this call. The state is every field the event shows and whether the row carries the
991
+ // import marker (not its value, which a rerun that adds it sets to its own read time).
992
+ const run = operator.requestId ? ['request', operator.requestId] : ['call', deps.generateId('run')];
993
+ const importAudit = (subscription) => ({
994
+ id: `evt_import_${createHash('sha256')
995
+ .update(JSON.stringify([
996
+ providerName,
997
+ reference,
998
+ billingAccount.id,
999
+ operator.id,
1000
+ operator.type ?? null,
1001
+ ...run,
1002
+ plan.planKey,
1003
+ input.reason ?? null,
1004
+ subscription.id,
1005
+ subscription.planKey,
1006
+ subscription.priceId,
1007
+ subscription.status,
1008
+ subscription.currentPeriodStart ?? null,
1009
+ subscription.currentPeriodEnd ?? null,
1010
+ subscription.autoRenew,
1011
+ subscription.metadata?.[BILLFN_IMPORTED_AT_METADATA_KEY] !== undefined
1012
+ ]))
1013
+ .digest('base64url')}`,
1014
+ billingAccountId: billingAccount.id,
1015
+ type: 'billfn.subscription.imported',
1016
+ payload: {
1017
+ subscriptionId: subscription.id,
1018
+ provider: providerName,
1019
+ providerReference: reference,
1020
+ planKey: subscription.planKey,
1021
+ priceId: subscription.priceId,
1022
+ status: subscription.status,
1023
+ currentPeriodStart: subscription.currentPeriodStart ?? null,
1024
+ currentPeriodEnd: subscription.currentPeriodEnd ?? null,
1025
+ autoRenew: subscription.autoRenew,
1026
+ operator,
1027
+ ...(input.reason ? { reason: input.reason } : {})
1028
+ },
1029
+ createdAt: toIsoString(deps.now())
1030
+ });
1031
+ let applied;
1032
+ try {
1033
+ // Racing imports end with one row: the loser of the unique reference insert applies an ordered update,
1034
+ // or fails as bound elsewhere when the winner bound another billing account.
1035
+ applied = await applyVerificationState(deps, {
1036
+ billingAccount,
1037
+ plan: selection.plan,
1038
+ price: selection.price,
1039
+ state,
1040
+ liveStateObservedAt: importObservedAt,
1041
+ // A rerun or a racing import keeps the first import's time.
1042
+ metadataDefaults: { [BILLFN_IMPORTED_AT_METADATA_KEY]: importObservedAt },
1043
+ // A write may retire the reference while the provider is read (the check above ran before the read).
1044
+ importReference: reference,
1045
+ importAudit
1046
+ });
1047
+ }
1048
+ catch (error) {
1049
+ if (hasErrorCode(error, 'BILLFN_CONFLICT') && (await isBoundElsewhere(deps, providerName, state, billingAccount.id))) {
1050
+ throw importBoundElsewhere(providerName);
1051
+ }
1052
+ throw error;
1053
+ }
1054
+ // Each write that left the row usable recorded its state's audit first. A run that wrote nothing (a read an
1055
+ // event superseded, or another run's accepted row) records the state it applied here, before the replay
1056
+ // changes anything, so a replay that does not finish still leaves this operator's event.
1057
+ await recordBillingEvent(deps, importAudit(applied.subscription));
1058
+ // A replay that does not finish fails the import; calling it again finishes the replay.
1059
+ try {
1060
+ await replayUnlinkedWebhookEventsOrThrow(deps, applied.subscription);
1061
+ }
1062
+ catch (error) {
1063
+ if (isReconciliationLeaseConflict(error)) {
1064
+ throw importReplayUnfinished();
1065
+ }
1066
+ throw error;
1067
+ }
1068
+ const refreshed = await refreshProjectionAfterReplay(deps, applied);
1069
+ // The event returned is the one for the state returned (after the replay), recorded here when the replay
1070
+ // changed it. The first write wins, so a rerun returns it as recorded.
1071
+ const recorded = importAudit(refreshed.subscription);
1072
+ await recordBillingEvent(deps, recorded);
1073
+ const billingEvent = (await deps.db.findOne({
1074
+ model: TABLES.billingEvents,
1075
+ where: [{ field: 'id', operator: 'eq', value: recorded.id }],
1076
+ namespace: deps.namespace
1077
+ })) ?? recorded;
1078
+ deps.metrics.track('subscription.imported', { provider: providerName });
1079
+ return ok({
1080
+ subscription: refreshed.subscription,
1081
+ entitlements: await withGrants(refreshed.subscription.billingAccountId, refreshed.entitlements),
1082
+ billingEvent
1083
+ });
1084
+ },
1085
+ async createGrant(input) {
1086
+ const grantInput = readCreateGrantInput(grantDeps, input);
1087
+ // Before the expiry check, so a refused request still records events an interrupted call left unrecorded.
1088
+ await repairGrantEvents(grantDeps, input.billingAccountId);
1089
+ requireFutureExpiry(grantDeps, grantInput);
1090
+ const billingAccount = await resolveOperatorBillingAccount(deps, input);
1091
+ const grant = await insertGrant(grantDeps, billingAccount.id, grantInput);
1092
+ return ok({ grant, entitlements: await findEffectiveEntitlements(billingAccount.id) });
1093
+ },
1094
+ async revokeGrant(input) {
1095
+ const grant = await revokeGrant(grantDeps, input);
1096
+ return ok({ grant, entitlements: await findEffectiveEntitlements(grant.billingAccountId) });
1097
+ },
899
1098
  async getEntitlements(subject) {
900
1099
  const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
901
1100
  const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
@@ -903,22 +1102,24 @@ function createBillFnServiceWithInternals(config) {
903
1102
  return ok({
904
1103
  billingAccount: buildVirtualBillingAccount(resolved, deps),
905
1104
  entitlements: null,
906
- subscription: null
1105
+ subscription: null,
1106
+ grants: []
907
1107
  });
908
1108
  }
909
- const entitlements = await findEntitlementSnapshot(deps, billingAccount.id);
1109
+ const { entitlements, grants } = await readEntitlements(billingAccount.id, true);
910
1110
  const subscription = await findActiveSubscription(deps, billingAccount.id);
911
1111
  const response = {
912
1112
  billingAccount,
913
1113
  entitlements,
914
- subscription
1114
+ subscription,
1115
+ grants: grants.map(toGrantSummary)
915
1116
  };
916
1117
  return ok(response);
917
1118
  },
918
1119
  async getUsage(subject, resource) {
919
1120
  const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
920
1121
  const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
921
- const entitlements = billingAccount ? await findEntitlementSnapshot(deps, billingAccount.id) : null;
1122
+ const entitlements = billingAccount ? await findEffectiveEntitlements(billingAccount.id) : null;
922
1123
  const meters = billingAccount
923
1124
  ? resource
924
1125
  ? await findUsageMeters(deps, billingAccount.id, resource)
@@ -1427,7 +1628,7 @@ async function processReconciliationJob(deps, job, assertLease) {
1427
1628
  }
1428
1629
  async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease, candidatePriceId) {
1429
1630
  const subscription = await getSubscriptionById(deps, subscriptionId);
1430
- if (!subscription) {
1631
+ if (!subscription || isImportPending(subscription)) {
1431
1632
  return;
1432
1633
  }
1433
1634
  const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
@@ -1623,6 +1824,287 @@ function requireProvider(deps, providerName, capability) {
1623
1824
  }
1624
1825
  return provider;
1625
1826
  }
1827
+ function readImportRequest(input) {
1828
+ const invalid = (message) => createBillFnError({ code: 'BILLFN_VALIDATION_ERROR', message });
1829
+ const optionalReference = (value, name) => {
1830
+ if (value === undefined) {
1831
+ return undefined;
1832
+ }
1833
+ if (typeof value !== 'string' || value.trim().length === 0 || value !== value.trim()) {
1834
+ throw invalid(`${name} must be a non-empty string without surrounding whitespace`);
1835
+ }
1836
+ return value;
1837
+ };
1838
+ const providerSubscriptionId = optionalReference(input.providerSubscriptionId, 'providerSubscriptionId');
1839
+ const originalTransactionId = optionalReference(input.originalTransactionId, 'originalTransactionId');
1840
+ if (originalTransactionId !== undefined && input.provider !== 'apple') {
1841
+ throw invalid('originalTransactionId identifies Apple subscriptions only');
1842
+ }
1843
+ if (providerSubscriptionId !== undefined && originalTransactionId !== undefined && providerSubscriptionId !== originalTransactionId) {
1844
+ throw invalid('providerSubscriptionId and originalTransactionId name different subscriptions');
1845
+ }
1846
+ const reference = providerSubscriptionId ?? originalTransactionId;
1847
+ if (reference === undefined) {
1848
+ throw invalid('Importing a subscription requires providerSubscriptionId or originalTransactionId');
1849
+ }
1850
+ if (typeof input.billingAccountId !== 'string' || input.billingAccountId.length === 0) {
1851
+ throw invalid('billingAccountId must be a non-empty string');
1852
+ }
1853
+ if (typeof input.planKey !== 'string' || input.planKey.length === 0) {
1854
+ throw invalid('planKey must be a non-empty string');
1855
+ }
1856
+ const operator = input.operator;
1857
+ if (!operator || typeof operator.id !== 'string' || operator.id.length === 0) {
1858
+ throw invalid('Importing a subscription requires the operator identity');
1859
+ }
1860
+ if (input.reason !== undefined && typeof input.reason !== 'string') {
1861
+ throw invalid('reason must be a string');
1862
+ }
1863
+ return {
1864
+ provider: input.provider,
1865
+ reference,
1866
+ operator: {
1867
+ id: operator.id,
1868
+ ...(typeof operator.type === 'string' ? { type: operator.type } : {}),
1869
+ ...(typeof operator.requestId === 'string' ? { requestId: operator.requestId } : {})
1870
+ }
1871
+ };
1872
+ }
1873
+ /**
1874
+ * Reads the provider record to import. A BillFn error from the adapter keeps its classification; any other failure
1875
+ * (the provider was not reached, or the adapter failed without classifying it) is a retryable provider error whose
1876
+ * message names only the provider, so no raw error text reaches the operator.
1877
+ */
1878
+ async function readImportState(provider, providerName, input) {
1879
+ try {
1880
+ return await provider.importSubscription(input);
1881
+ }
1882
+ catch (error) {
1883
+ const code = error?.code;
1884
+ if (typeof code === 'string' && code.startsWith('BILLFN_')) {
1885
+ throw error;
1886
+ }
1887
+ throw createBillFnError({
1888
+ code: 'BILLFN_PROVIDER_ERROR',
1889
+ message: `Provider ${providerName} could not be read for the imported subscription`
1890
+ });
1891
+ }
1892
+ }
1893
+ /** The billing account an import or grant binds to: created for the subject when one is given, otherwise existing. */
1894
+ async function resolveOperatorBillingAccount(deps, input) {
1895
+ if (input.subject) {
1896
+ const subject = requireSubject(input.subject);
1897
+ const resolved = await resolveBillingAccountReference(deps, subject);
1898
+ if (resolved.billingAccountId !== input.billingAccountId) {
1899
+ throw createBillFnError({
1900
+ code: 'BILLFN_VALIDATION_ERROR',
1901
+ message: 'The subject resolves to a different billing account'
1902
+ });
1903
+ }
1904
+ return (await getOrCreateBillingAccount(deps, subject)).billingAccount;
1905
+ }
1906
+ const billingAccount = await getBillingAccountById(deps, input.billingAccountId);
1907
+ if (!billingAccount) {
1908
+ throw createBillFnError({
1909
+ code: 'BILLFN_NOT_FOUND',
1910
+ message: 'Billing account not found'
1911
+ });
1912
+ }
1913
+ return billingAccount;
1914
+ }
1915
+ /**
1916
+ * The plan's subscription price the provider record's product names. Every reference (the adapter's
1917
+ * `providerProductId`, then the record's `product_id`/`productId`) is a provider product id here, so it is matched
1918
+ * only against `providerProductId`, never against a catalog `priceId` another plan's product could equal.
1919
+ */
1920
+ function requireImportPrice(plan, provider, state) {
1921
+ const raw = asRecord(state.raw);
1922
+ const records = [raw, asRecord(raw?.transaction)];
1923
+ const references = [
1924
+ state.providerProductId,
1925
+ ...records.flatMap((record) => (record ? ['product_id', 'productId'].map((key) => readString(record, key)) : []))
1926
+ ].filter((reference) => Boolean(reference));
1927
+ const prices = plan.prices.filter((price) => price.provider === provider && price.kind === 'subscription');
1928
+ for (const reference of references) {
1929
+ const price = prices.find((candidate) => candidate.providerProductId === reference);
1930
+ if (price) {
1931
+ return { plan, price };
1932
+ }
1933
+ }
1934
+ throw createBillFnError({
1935
+ code: 'BILLFN_VALIDATION_ERROR',
1936
+ message: `The provider subscription's product is not a ${provider} subscription price of plan ${plan.planKey}`,
1937
+ details: { planKey: plan.planKey, providerProductIds: references }
1938
+ });
1939
+ }
1940
+ function importBoundElsewhere(provider) {
1941
+ return createBillFnError({
1942
+ code: 'BILLFN_CONFLICT',
1943
+ message: `The ${provider} subscription is already bound to a different billing account`
1944
+ });
1945
+ }
1946
+ /** Whether a subscription of another billing account holds a reference the provider record names. */
1947
+ async function isBoundElsewhere(deps, provider, state, billingAccountId) {
1948
+ const holder = await findSubscriptionByProviderRef(deps, provider, state.providerSubscriptionId, state.providerChargeId);
1949
+ return Boolean(holder && holder.billingAccountId !== billingAccountId);
1950
+ }
1951
+ /**
1952
+ * The import is bound and recorded, but another run holds the reference's
1953
+ * unlinked events (including a run interrupted before it released them) or the
1954
+ * replay reached its bound. Unlike a bound-elsewhere conflict it is retryable:
1955
+ * calling import again, after the holder's lease expires, finishes the replay.
1956
+ */
1957
+ function importReplayUnfinished() {
1958
+ return createBillFnError({
1959
+ code: 'BILLFN_CONFLICT',
1960
+ message: 'The import is recorded but replaying its unlinked webhook events did not finish; import again to finish it',
1961
+ details: { reason: BILLFN_IMPORT_REPLAY_UNFINISHED_REASON },
1962
+ retryable: true
1963
+ });
1964
+ }
1965
+ function importReferenceReplaced() {
1966
+ return createBillFnError({
1967
+ code: 'BILLFN_CONFLICT',
1968
+ message: 'The provider reference was replaced by another subscription and cannot be imported'
1969
+ });
1970
+ }
1971
+ /**
1972
+ * Refuses an import of a reference a subscription write retired: deletes
1973
+ * `holder` when it is a pending import row holding the reference (whichever
1974
+ * import run created it), then fails with a non-retryable `BILLFN_CONFLICT`.
1975
+ * An accepted row is never deleted: the delete is ordered against a fresh
1976
+ * read that is still pending, so a stale `holder` another run has accepted
1977
+ * since survives.
1978
+ */
1979
+ async function refuseRetiredImportReference(deps, provider, reference, holder) {
1980
+ if (!(await isReplacedProviderReference(deps, provider, reference, holder?.id))) {
1981
+ return;
1982
+ }
1983
+ const stored = holder && isImportPending(holder) ? await getSubscriptionById(deps, holder.id) : null;
1984
+ if (stored && isImportPending(stored) && stored.providerSubscriptionId === reference) {
1985
+ await deps.db.deleteMany({
1986
+ model: TABLES.subscriptions,
1987
+ where: [
1988
+ { field: 'id', operator: 'eq', value: stored.id },
1989
+ { field: 'providerSubscriptionId', operator: 'eq', value: reference },
1990
+ { field: 'updatedAt', operator: 'eq', value: stored.updatedAt }
1991
+ ],
1992
+ namespace: deps.namespace
1993
+ });
1994
+ }
1995
+ deps.metrics.track('subscription.import.replaced_reference_refused', { provider });
1996
+ throw importReferenceReplaced();
1997
+ }
1998
+ /**
1999
+ * After an import's write to `subscription`, which holds `reference`: refuses
2000
+ * the binding when a write of another subscription retired the reference, or
2001
+ * accepts it by clearing the pending marker, and returns the accepted row:
2002
+ * `ownState` when that row carries this write's state, and `acceptedHere` when
2003
+ * this call cleared its pending marker. A lost acceptance write re-finds the
2004
+ * row, which then carries another run's state (accepted by it or here), so the
2005
+ * caller publishes that row's plan, never its own. Every row, the first and
2006
+ * each reload, is checked the same way before it is accepted or returned: it
2007
+ * holds the reference, belongs to `billingAccountId` (another account's row
2008
+ * fails as bound elsewhere, untouched) and its reference is not retired.
2009
+ * Nothing uses a pending row, so no entitlement or lifecycle event exists for
2010
+ * it until this accepts it. The import's audit event (`audit`) for the row it
2011
+ * accepts is recorded just before each acceptance write, so an accepted
2012
+ * binding always has it (an event for a write that then does not land stays);
2013
+ * a refusal deletes the row, and an interrupted run leaves it pending for the
2014
+ * rerun to settle.
2015
+ *
2016
+ * Why the check after the write is enough: a reference is held by at most one
2017
+ * row (unique index), so a writer that retired it took it off its row before
2018
+ * this write held it, and recorded it before that, or saved it pending in that
2019
+ * same write. Read pending, then recorded, now, one of the reads sees it. Every
2020
+ * import that holds the row therefore reaches the same answer.
2021
+ */
2022
+ async function settleImportBinding(deps, subscription, reference, billingAccountId, audit) {
2023
+ let current = subscription;
2024
+ for (let attempt = 0; attempt < 50; attempt += 1) {
2025
+ // Only the first attempt holds this run's write; a reload holds whatever wrote the row after it.
2026
+ const ownState = attempt === 0;
2027
+ if (current?.providerSubscriptionId !== reference) {
2028
+ throw createBillFnError({
2029
+ code: 'BILLFN_CONFLICT',
2030
+ message: 'The imported subscription changed while its import was accepted',
2031
+ retryable: true
2032
+ });
2033
+ }
2034
+ if (current.billingAccountId !== billingAccountId) {
2035
+ throw importBoundElsewhere(current.provider);
2036
+ }
2037
+ await refuseRetiredImportReference(deps, current.provider, reference, current);
2038
+ if (!isImportPending(current)) {
2039
+ return { subscription: current, ownState, acceptedHere: false };
2040
+ }
2041
+ const accepted = {
2042
+ ...current,
2043
+ metadata: withoutImportPending(current.metadata),
2044
+ updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
2045
+ };
2046
+ if (await updateSubscriptionAfterImportAudit(deps, current, accepted, audit)) {
2047
+ return { subscription: accepted, ownState, acceptedHere: true };
2048
+ }
2049
+ current = await findSubscriptionByProviderRef(deps, subscription.provider, reference);
2050
+ await Promise.resolve();
2051
+ }
2052
+ throw createBillFnError({
2053
+ code: 'BILLFN_CONFLICT',
2054
+ message: 'Unable to accept the imported subscription after multiple retries',
2055
+ retryable: true
2056
+ });
2057
+ }
2058
+ /** Whether the row is an import's binding not yet accepted (see `BILLFN_IMPORT_PENDING_METADATA_KEY`). */
2059
+ function isImportPending(subscription) {
2060
+ return subscription?.metadata?.[BILLFN_IMPORT_PENDING_METADATA_KEY] === true;
2061
+ }
2062
+ function withoutImportPending(metadata) {
2063
+ if (!metadata) {
2064
+ return metadata;
2065
+ }
2066
+ const { [BILLFN_IMPORT_PENDING_METADATA_KEY]: _pending, ...rest } = metadata;
2067
+ return rest;
2068
+ }
2069
+ /** A write other than an import's reached a pending import row: retried once the import settles it. */
2070
+ function importPendingConflict() {
2071
+ return createBillFnError({
2072
+ code: 'BILLFN_CONFLICT',
2073
+ message: 'The subscription is being imported',
2074
+ details: { reason: 'subscription_import_pending' },
2075
+ retryable: true
2076
+ });
2077
+ }
2078
+ /**
2079
+ * Whether a subscription write dropped this reference (still pending on the
2080
+ * subscription, or recorded). A writer records the reference before it clears
2081
+ * pending, so pending is read first, as `namesReplacedReference` does: a writer
2082
+ * that clears pending between the reads has already recorded it. `holderId`,
2083
+ * the row that holds the reference now, is not counted: a reference it dropped
2084
+ * and holds again, or one a write that lost its race on it recorded, is held.
2085
+ */
2086
+ async function isReplacedProviderReference(deps, provider, reference, holderId) {
2087
+ const pending = await deps.db.findMany({
2088
+ model: TABLES.subscriptions,
2089
+ where: [
2090
+ { field: 'provider', operator: 'eq', value: provider },
2091
+ { field: 'replacedReferencesPending', operator: 'eq', value: true }
2092
+ ],
2093
+ namespace: deps.namespace
2094
+ });
2095
+ if (pending.some((subscription) => subscription.id !== holderId && pendingReplacedReferencesOf(subscription).includes(reference))) {
2096
+ return true;
2097
+ }
2098
+ const replaced = await deps.db.findOne({
2099
+ model: TABLES.replacedProviderReferences,
2100
+ where: [
2101
+ { field: 'provider', operator: 'eq', value: provider },
2102
+ { field: 'providerReference', operator: 'eq', value: reference }
2103
+ ],
2104
+ namespace: deps.namespace
2105
+ });
2106
+ return Boolean(replaced && replaced.subscriptionId !== holderId);
2107
+ }
1626
2108
  function requireSubject(subject) {
1627
2109
  if (!subject) {
1628
2110
  throw createBillFnError({
@@ -1678,7 +2160,7 @@ async function getSubscriptionById(deps, id) {
1678
2160
  }
1679
2161
  async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId) {
1680
2162
  if (subscriptionId) {
1681
- return deps.db.findOne({
2163
+ const subscription = await deps.db.findOne({
1682
2164
  model: TABLES.subscriptions,
1683
2165
  where: [
1684
2166
  { field: 'id', operator: 'eq', value: subscriptionId },
@@ -1686,6 +2168,7 @@ async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId)
1686
2168
  ],
1687
2169
  namespace: deps.namespace
1688
2170
  });
2171
+ return isImportPending(subscription) ? null : subscription;
1689
2172
  }
1690
2173
  return findActiveSubscription(deps, billingAccountId);
1691
2174
  }
@@ -1699,7 +2182,8 @@ async function findActiveSubscription(deps, billingAccountId) {
1699
2182
  orderBy: [{ field: 'updatedAt', direction: 'desc' }],
1700
2183
  namespace: deps.namespace
1701
2184
  });
1702
- return candidates[0] ?? null;
2185
+ // A pending import row is not the account's until its import accepts it.
2186
+ return candidates.find((candidate) => !isImportPending(candidate)) ?? null;
1703
2187
  }
1704
2188
  async function findSubscriptionByProviderRef(deps, provider, providerSubscriptionId, providerChargeId, checkoutReferenceId) {
1705
2189
  const whereSets = [];
@@ -1999,12 +2483,23 @@ async function applyVerificationState(deps, input) {
1999
2483
  // A read is superseded by an event newer than its start; a committed mutation only by one after its commit.
2000
2484
  const supersededBefore = input.committedAt ?? input.liveStateObservedAt;
2001
2485
  for (let attempt = 0; attempt < 50; attempt += 1) {
2486
+ if (!input.importReference && isImportPending(baseSubscription)) {
2487
+ // Only the import settles its pending row; anything else applies once it is accepted (or deleted).
2488
+ throw importPendingConflict();
2489
+ }
2002
2490
  if (baseSubscription && baseSubscription.billingAccountId !== input.billingAccount.id) {
2003
2491
  throw createBillFnError({
2004
2492
  code: 'BILLFN_CONFLICT',
2005
2493
  message: 'Provider billing reference belongs to a different billing account'
2006
2494
  });
2007
2495
  }
2496
+ if (input.importReference && baseSubscription && baseSubscription.providerSubscriptionId !== input.importReference) {
2497
+ // Found by another reference the provider record names: an import never moves its reference onto such a row.
2498
+ throw createBillFnError({
2499
+ code: 'BILLFN_CONFLICT',
2500
+ message: 'The provider record names a reference another subscription holds'
2501
+ });
2502
+ }
2008
2503
  const order = baseSubscription && input.occurredAt ? orderProviderEvent(baseSubscription, input.occurredAt) : 'apply';
2009
2504
  if (baseSubscription && order === 'skip') {
2010
2505
  // The older event's state is skipped, but a reference it adds still belongs to this subscription.
@@ -2037,21 +2532,13 @@ async function applyVerificationState(deps, input) {
2037
2532
  subscription = baseSubscription;
2038
2533
  break;
2039
2534
  }
2040
- const updated = await deps.db.updateMany({
2041
- model: TABLES.subscriptions,
2042
- where: [
2043
- { field: 'id', operator: 'eq', value: baseSubscription.id },
2044
- { field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
2045
- ],
2046
- data: linked,
2047
- namespace: deps.namespace
2048
- });
2049
- if (updated === 1) {
2535
+ // An import that adds its marker or references to an accepted row records its audit first.
2536
+ if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, linked, input.importAudit)) {
2050
2537
  subscription = linked;
2051
2538
  break;
2052
2539
  }
2053
- const current = await getSubscriptionById(deps, baseSubscription.id);
2054
- if (!current) {
2540
+ const current = await reloadAfterLostWrite(deps, input, baseSubscription);
2541
+ if (!current && !input.importReference) {
2055
2542
  throw createBillFnError({
2056
2543
  code: 'BILLFN_CONFLICT',
2057
2544
  message: 'Subscription changed while applying provider state'
@@ -2062,16 +2549,20 @@ async function applyVerificationState(deps, input) {
2062
2549
  continue;
2063
2550
  }
2064
2551
  // A state write closes the window of an earlier committed mutation; a committed mutation opens its own.
2065
- const merged = mergeJson(baseSubscription?.metadata, input.state.raw);
2552
+ const merged = mergeJson(mergeJson(input.metadataDefaults, baseSubscription?.metadata), input.state.raw);
2066
2553
  const mergedMetadata = input.keepMutationWindow ? merged : withoutMutationWindow(merged);
2067
2554
  const providerStateAt = providerStateTimeFor(input.occurredAt, supersededBefore, baseSubscription);
2068
- const metadata = providerStateAt
2069
- ? {
2070
- ...mergedMetadata,
2071
- [PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
2072
- ...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
2073
- }
2074
- : mergedMetadata;
2555
+ const metadata = {
2556
+ ...mergedMetadata,
2557
+ ...(providerStateAt
2558
+ ? {
2559
+ [PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
2560
+ ...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
2561
+ }
2562
+ : {}),
2563
+ // A row an import creates stays pending until `settleImportBinding` accepts it.
2564
+ ...(input.importReference && !baseSubscription ? { [BILLFN_IMPORT_PENDING_METADATA_KEY]: true } : {})
2565
+ };
2075
2566
  const subscriptionId = baseSubscription?.id ?? deps.generateId('sub');
2076
2567
  const record = {
2077
2568
  id: subscriptionId,
@@ -2101,23 +2592,15 @@ async function applyVerificationState(deps, input) {
2101
2592
  const pending = await recordDroppedReferences(deps, baseSubscription, record, Boolean(input.committedAt));
2102
2593
  Object.assign(record, withPendingReplacements(record.metadata, pending));
2103
2594
  // Every update is ordered against the row it was computed from, so a provider-state time and the
2104
- // pending references it clears always come from the row it replaces.
2105
- const updated = await deps.db.updateMany({
2106
- model: TABLES.subscriptions,
2107
- where: [
2108
- { field: 'id', operator: 'eq', value: baseSubscription.id },
2109
- { field: 'updatedAt', operator: 'eq', value: baseSubscription.updatedAt }
2110
- ],
2111
- data: record,
2112
- namespace: deps.namespace
2113
- });
2114
- if (updated === 1) {
2595
+ // pending references it clears always come from the row it replaces. A re-import of an accepted row
2596
+ // records its audit first.
2597
+ if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, record, input.importAudit)) {
2115
2598
  subscription = record;
2116
2599
  break;
2117
2600
  }
2118
2601
  // A lost race leaves its records: a reference the winner keeps is held, so its events still link to it.
2119
- const current = await getSubscriptionById(deps, baseSubscription.id);
2120
- if (!current) {
2602
+ const current = await reloadAfterLostWrite(deps, input, baseSubscription);
2603
+ if (!current && !input.importReference) {
2121
2604
  throw createBillFnError({
2122
2605
  code: 'BILLFN_CONFLICT',
2123
2606
  message: 'Subscription changed while applying provider event state'
@@ -2153,6 +2636,16 @@ async function applyVerificationState(deps, input) {
2153
2636
  message: 'Unable to apply provider event state after multiple retries'
2154
2637
  });
2155
2638
  }
2639
+ // Whether the row carries this call's state; otherwise it publishes the row's own plan, never `input.plan`.
2640
+ let ownState = !liveStateSuperseded;
2641
+ // Whether this call records the row's lifecycle event (an import that accepts another run's state records it).
2642
+ let recordLifecycle = ownState;
2643
+ if (input.importReference) {
2644
+ const settled = await settleImportBinding(deps, subscription, input.importReference, input.billingAccount.id, input.importAudit);
2645
+ subscription = settled.subscription;
2646
+ ownState = ownState && settled.ownState;
2647
+ recordLifecycle = ownState || settled.acceptedHere;
2648
+ }
2156
2649
  const checkoutSession = input.checkoutSession
2157
2650
  ? await updateCheckoutSession(deps, input.checkoutSession.checkoutSessionId, {
2158
2651
  status: input.state.checkoutStatus,
@@ -2162,9 +2655,10 @@ async function applyVerificationState(deps, input) {
2162
2655
  metadata: mergeJson(input.checkoutSession.metadata, input.state.raw)
2163
2656
  })
2164
2657
  : input.checkoutSession;
2165
- if (liveStateSuperseded) {
2166
- // The subscription's state did not change, so its entitlements stand and no lifecycle event is recorded.
2167
- const entitlements = await upsertEntitlements(deps, input.billingAccount.id, requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
2658
+ // A superseded read leaves the subscription's state, so its entitlements stand and no lifecycle event is recorded;
2659
+ // an import that ends on another run's state publishes that state's plan.
2660
+ const entitlements = await upsertEntitlements(deps, input.billingAccount.id, ownState ? input.plan : requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
2661
+ if (!recordLifecycle) {
2168
2662
  return {
2169
2663
  checkoutSession: checkoutSession ?? input.checkoutSession,
2170
2664
  subscription,
@@ -2172,7 +2666,6 @@ async function applyVerificationState(deps, input) {
2172
2666
  providerStateRaced
2173
2667
  };
2174
2668
  }
2175
- const entitlements = await upsertEntitlements(deps, input.billingAccount.id, input.plan, subscription, timestamp);
2176
2669
  await recordBillingEvent(deps, {
2177
2670
  // A provider event keys its lifecycle entry, so applying it again (a reclaimed replay) records it once.
2178
2671
  id: input.providerEventId
@@ -2198,6 +2691,17 @@ async function applyVerificationState(deps, input) {
2198
2691
  providerStateRaced
2199
2692
  };
2200
2693
  }
2694
+ /**
2695
+ * The row to retry a lost ordered write against: the row again, or for an
2696
+ * import the row that holds its reference now (null when none does, and the
2697
+ * import creates one), so the import never writes its reference back onto a
2698
+ * row a writer took it from.
2699
+ */
2700
+ function reloadAfterLostWrite(deps, input, subscription) {
2701
+ return input.importReference
2702
+ ? findSubscriptionByProviderRef(deps, input.price.provider, input.importReference)
2703
+ : getSubscriptionById(deps, subscription.id);
2704
+ }
2201
2705
  /**
2202
2706
  * The provider-state time a write saves: the event's time, or for live state
2203
2707
  * the read start (a committed mutation: its commit), never moving the stored
@@ -2209,19 +2713,22 @@ function providerStateTimeFor(occurredAt, liveStateAt, subscription) {
2209
2713
  }
2210
2714
  /**
2211
2715
  * The subscription row after a live provider read that an applied event has
2212
- * superseded: missing provider references from the read are added and every
2213
- * state field is kept. Returns null when the read adds no reference.
2716
+ * superseded: missing provider references and metadata keys from the read are
2717
+ * added and every state field is kept. Returns null when the read adds neither.
2214
2718
  */
2215
2719
  function linkLiveReadReferences(subscription, input, timestamp) {
2216
2720
  const providerSubscriptionId = subscription.providerSubscriptionId ?? input.state.providerSubscriptionId;
2217
2721
  const providerCheckoutId = subscription.providerCheckoutId ?? input.checkoutSession?.providerCheckoutId;
2218
2722
  const providerChargeId = subscription.providerChargeId ?? input.state.providerChargeId;
2723
+ const addsMetadata = Object.keys(input.metadataDefaults ?? {}).some((key) => subscription.metadata?.[key] === undefined);
2219
2724
  if (providerSubscriptionId === subscription.providerSubscriptionId &&
2220
2725
  providerCheckoutId === subscription.providerCheckoutId &&
2221
- providerChargeId === subscription.providerChargeId) {
2726
+ providerChargeId === subscription.providerChargeId &&
2727
+ !addsMetadata) {
2222
2728
  return null;
2223
2729
  }
2224
- return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, updatedAt: timestamp };
2730
+ const metadata = addsMetadata ? { ...input.metadataDefaults, ...subscription.metadata } : subscription.metadata;
2731
+ return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, metadata, updatedAt: timestamp };
2225
2732
  }
2226
2733
  /**
2227
2734
  * Links the provider references of an event skipped as older than the
@@ -2274,6 +2781,28 @@ async function linkableMissingReferences(deps, subscription, state) {
2274
2781
  }
2275
2782
  return data;
2276
2783
  }
2784
+ /**
2785
+ * The ordered update of an existing row that `applyVerificationState` (state and superseded-read link updates)
2786
+ * and an import's acceptance (`settleImportBinding`) go through. With an import's audit, a write that leaves the
2787
+ * row usable (not pending) records the run's audit for the state it publishes first, so no later failure or
2788
+ * interruption leaves an import's change without its operator. The row an import creates is pending, so it is
2789
+ * audited when this accepts it.
2790
+ */
2791
+ async function updateSubscriptionAfterImportAudit(deps, current, next, importAudit) {
2792
+ if (importAudit && !isImportPending(next)) {
2793
+ await recordBillingEvent(deps, importAudit(next));
2794
+ }
2795
+ const updated = await deps.db.updateMany({
2796
+ model: TABLES.subscriptions,
2797
+ where: [
2798
+ { field: 'id', operator: 'eq', value: current.id },
2799
+ { field: 'updatedAt', operator: 'eq', value: current.updatedAt }
2800
+ ],
2801
+ data: next,
2802
+ namespace: deps.namespace
2803
+ });
2804
+ return updated === 1;
2805
+ }
2277
2806
  /** Writes a subscription row ordered against the row it was computed from; false when that row changed. */
2278
2807
  async function writeOrderedSubscription(deps, current, next) {
2279
2808
  try {
@@ -2376,7 +2905,8 @@ async function recordBillingEvent(deps, event) {
2376
2905
  });
2377
2906
  }
2378
2907
  catch (error) {
2379
- // A provider-event-keyed entry already recorded by an earlier apply of the same event.
2908
+ // Already recorded: a provider-event-keyed entry by an earlier apply of the same event, or a digest-keyed
2909
+ // import event by an earlier write of the same import run and state. The first write wins.
2380
2910
  if (!isDuplicateRecordError(error)) {
2381
2911
  throw error;
2382
2912
  }
@@ -2536,6 +3066,10 @@ async function appendUsageLedgerEntry(deps, billingAccountId, resource, amount)
2536
3066
  }
2537
3067
  async function resolveProjectionContextForProviderState(deps, input) {
2538
3068
  const subscription = await findSubscriptionByProviderRef(deps, input.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutReferenceId);
3069
+ if (isImportPending(subscription)) {
3070
+ // Not usable until its import accepts it: the event is stored unlinked and replayed then.
3071
+ return null;
3072
+ }
2539
3073
  if (subscription) {
2540
3074
  const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
2541
3075
  if (!billingAccount) {
@@ -2694,7 +3228,12 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
2694
3228
  const metadataBillingAccountId = readBillingAccountMetadata(event, state);
2695
3229
  const existing = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
2696
3230
  let reason;
2697
- if (existing) {
3231
+ const importPending = isImportPending(existing);
3232
+ if (importPending) {
3233
+ // Its import replays this event once it accepts the row; a refused import leaves it unlinked.
3234
+ reason = 'subscription_import_pending';
3235
+ }
3236
+ else if (existing) {
2698
3237
  // A link that committed after this event's first lookup is applied to like any linked reference.
2699
3238
  const projection = await resolveProjectionContextForProviderState(deps, {
2700
3239
  provider: providerName,
@@ -2726,10 +3265,11 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
2726
3265
  reason,
2727
3266
  metadataBillingAccountId
2728
3267
  });
2729
- if (existing) {
3268
+ if (existing && !importPending) {
2730
3269
  return { handled: null };
2731
3270
  }
2732
- // A link that committed while this event was being stored would have missed it; replay it now.
3271
+ // A link (or an import's acceptance) that committed while this event was being stored would have missed it;
3272
+ // replay it now. The replay skips a row still pending.
2733
3273
  const linked = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
2734
3274
  if (linked) {
2735
3275
  await replayUnlinkedWebhookEventsOrThrow(deps, linked);
@@ -2878,6 +3418,10 @@ async function purgeExpiredUnlinkedWebhookEvents(deps, timestamp) {
2878
3418
  */
2879
3419
  async function replayUnlinkedWebhookEvents(deps, subscription) {
2880
3420
  const result = { applied: 0, skipped: 0, pending: 0 };
3421
+ if (isImportPending(subscription)) {
3422
+ // A pending import row is not linked yet: its import replays the events once it accepts the row.
3423
+ return result;
3424
+ }
2881
3425
  const queue = createUnlinkedReplayQueue(deps, subscription.provider);
2882
3426
  let latest = subscription;
2883
3427
  await queue.discover(subscription);