@billfn/core 0.2.0 → 0.3.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,3 +1,4 @@
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';
@@ -33,6 +34,23 @@ const UNLINKED_WEBHOOK_EVENT_REPLAY_LEASE_MS = 15 * 60 * 1000;
33
34
  const UNLINKED_WEBHOOK_EVENT_REPLAY_MAX_EVENTS = 500;
34
35
  /** Provider metadata key naming the billing account that owns a provider record. */
35
36
  const BILLING_ACCOUNT_METADATA_KEY = 'billfn_billing_account_id';
37
+ /**
38
+ * Subscription metadata key set when an operator imported the subscription:
39
+ * when it was first imported. Its binding to the billing account is the
40
+ * operator's, so a provider adapter does not expect the BillFn ownership
41
+ * metadata a subscription created outside BillFn lacks.
42
+ */
43
+ export const BILLFN_IMPORTED_AT_METADATA_KEY = 'billfnImportedAt';
44
+ /**
45
+ * Subscription metadata key set on the row an import creates until its
46
+ * binding is accepted (`settleImportBinding`). A pending row is not usable:
47
+ * webhooks and replays leave events for it unlinked, other writes refuse it
48
+ * (retryably) and subject, account and sync lookups skip it. A refusal deletes
49
+ * it, on the import's first run or on any rerun, so it never becomes durable.
50
+ */
51
+ export const BILLFN_IMPORT_PENDING_METADATA_KEY = 'billfnImportPending';
52
+ /** `details.reason` of the retryable `BILLFN_CONFLICT` an import raises when its replay did not finish. */
53
+ export const BILLFN_IMPORT_REPLAY_UNFINISHED_REASON = 'import_replay_unfinished';
36
54
  /** Deliveries of one queue message refused by lease conflicts before it is dead-lettered. */
37
55
  const RECONCILIATION_QUEUE_MAX_LEASE_CONFLICT_ATTEMPTS = 5;
38
56
  /**
@@ -896,6 +914,142 @@ function createBillFnServiceWithInternals(config) {
896
914
  message: 'No restored purchase could be linked to the resolved billing account'
897
915
  });
898
916
  },
917
+ async importSubscription(input) {
918
+ const { provider: providerName, reference, operator } = readImportRequest(input);
919
+ const plan = requirePlan(deps.catalog, input.planKey);
920
+ if (!plan.prices.some((price) => price.provider === providerName && price.kind === 'subscription')) {
921
+ throw createBillFnError({
922
+ code: 'BILLFN_VALIDATION_ERROR',
923
+ message: `Plan ${plan.planKey} has no ${providerName} subscription price`
924
+ });
925
+ }
926
+ const provider = requireProvider(deps, providerName, 'importSubscription');
927
+ // A reference replaced by a subscription write, or bound to another billing account, fails before the provider is read.
928
+ // A pending row (an import not yet accepted, perhaps interrupted) is checked like an unbound reference and
929
+ // removed when the reference is retired, so a rerun of a refused import converges to no binding.
930
+ const bound = await findSubscriptionByProviderRef(deps, providerName, reference);
931
+ if (!bound || isImportPending(bound)) {
932
+ await refuseRetiredImportReference(deps, providerName, reference, bound);
933
+ }
934
+ if (bound && bound.billingAccountId !== input.billingAccountId) {
935
+ throw importBoundElsewhere(providerName);
936
+ }
937
+ // Resolved after the checks above, so an import they refuse never creates the subject's billing account;
938
+ // one refused after the provider read keeps it.
939
+ const billingAccount = await resolveImportBillingAccount(deps, input);
940
+ // Events that occurred before this live read are already reflected in its state.
941
+ const importObservedAt = toIsoString(deps.now());
942
+ const state = await readImportState(provider, providerName, { providerReference: reference, billingAccount, plan });
943
+ if (!state) {
944
+ throw createBillFnError({
945
+ code: 'BILLFN_NOT_FOUND',
946
+ message: 'Provider did not return the subscription to import'
947
+ });
948
+ }
949
+ if (state.providerSubscriptionId !== reference) {
950
+ throw createBillFnError({
951
+ code: 'BILLFN_PROVIDER_ERROR',
952
+ message: 'The provider record does not match the imported reference'
953
+ });
954
+ }
955
+ const selection = requireImportPrice(plan, providerName, state);
956
+ // One audit event per import run and state it records, never changed once written. The run is the operator's
957
+ // request (a rerun of it that records a state it already recorded adds none, even after another state) or,
958
+ // without a requestId, this call. The state is every field the event shows and whether the row carries the
959
+ // import marker (not its value, which a rerun that adds it sets to its own read time).
960
+ const run = operator.requestId ? ['request', operator.requestId] : ['call', deps.generateId('run')];
961
+ const importAudit = (subscription) => ({
962
+ id: `evt_import_${createHash('sha256')
963
+ .update(JSON.stringify([
964
+ providerName,
965
+ reference,
966
+ billingAccount.id,
967
+ operator.id,
968
+ operator.type ?? null,
969
+ ...run,
970
+ plan.planKey,
971
+ input.reason ?? null,
972
+ subscription.id,
973
+ subscription.planKey,
974
+ subscription.priceId,
975
+ subscription.status,
976
+ subscription.currentPeriodStart ?? null,
977
+ subscription.currentPeriodEnd ?? null,
978
+ subscription.autoRenew,
979
+ subscription.metadata?.[BILLFN_IMPORTED_AT_METADATA_KEY] !== undefined
980
+ ]))
981
+ .digest('base64url')}`,
982
+ billingAccountId: billingAccount.id,
983
+ type: 'billfn.subscription.imported',
984
+ payload: {
985
+ subscriptionId: subscription.id,
986
+ provider: providerName,
987
+ providerReference: reference,
988
+ planKey: subscription.planKey,
989
+ priceId: subscription.priceId,
990
+ status: subscription.status,
991
+ currentPeriodStart: subscription.currentPeriodStart ?? null,
992
+ currentPeriodEnd: subscription.currentPeriodEnd ?? null,
993
+ autoRenew: subscription.autoRenew,
994
+ operator,
995
+ ...(input.reason ? { reason: input.reason } : {})
996
+ },
997
+ createdAt: toIsoString(deps.now())
998
+ });
999
+ let applied;
1000
+ try {
1001
+ // Racing imports end with one row: the loser of the unique reference insert applies an ordered update,
1002
+ // or fails as bound elsewhere when the winner bound another billing account.
1003
+ applied = await applyVerificationState(deps, {
1004
+ billingAccount,
1005
+ plan: selection.plan,
1006
+ price: selection.price,
1007
+ state,
1008
+ liveStateObservedAt: importObservedAt,
1009
+ // A rerun or a racing import keeps the first import's time.
1010
+ metadataDefaults: { [BILLFN_IMPORTED_AT_METADATA_KEY]: importObservedAt },
1011
+ // A write may retire the reference while the provider is read (the check above ran before the read).
1012
+ importReference: reference,
1013
+ importAudit
1014
+ });
1015
+ }
1016
+ catch (error) {
1017
+ if (hasErrorCode(error, 'BILLFN_CONFLICT') && (await isBoundElsewhere(deps, providerName, state, billingAccount.id))) {
1018
+ throw importBoundElsewhere(providerName);
1019
+ }
1020
+ throw error;
1021
+ }
1022
+ // Each write that left the row usable recorded its state's audit first. A run that wrote nothing (a read an
1023
+ // event superseded, or another run's accepted row) records the state it applied here, before the replay
1024
+ // changes anything, so a replay that does not finish still leaves this operator's event.
1025
+ await recordBillingEvent(deps, importAudit(applied.subscription));
1026
+ // A replay that does not finish fails the import; calling it again finishes the replay.
1027
+ try {
1028
+ await replayUnlinkedWebhookEventsOrThrow(deps, applied.subscription);
1029
+ }
1030
+ catch (error) {
1031
+ if (isReconciliationLeaseConflict(error)) {
1032
+ throw importReplayUnfinished();
1033
+ }
1034
+ throw error;
1035
+ }
1036
+ const refreshed = await refreshProjectionAfterReplay(deps, applied);
1037
+ // The event returned is the one for the state returned (after the replay), recorded here when the replay
1038
+ // changed it. The first write wins, so a rerun returns it as recorded.
1039
+ const recorded = importAudit(refreshed.subscription);
1040
+ await recordBillingEvent(deps, recorded);
1041
+ const billingEvent = (await deps.db.findOne({
1042
+ model: TABLES.billingEvents,
1043
+ where: [{ field: 'id', operator: 'eq', value: recorded.id }],
1044
+ namespace: deps.namespace
1045
+ })) ?? recorded;
1046
+ deps.metrics.track('subscription.imported', { provider: providerName });
1047
+ return ok({
1048
+ subscription: refreshed.subscription,
1049
+ entitlements: refreshed.entitlements,
1050
+ billingEvent
1051
+ });
1052
+ },
899
1053
  async getEntitlements(subject) {
900
1054
  const resolved = await resolveBillingAccountReference(deps, requireSubject(subject));
901
1055
  const billingAccount = await getBillingAccountById(deps, resolved.billingAccountId);
@@ -1427,7 +1581,7 @@ async function processReconciliationJob(deps, job, assertLease) {
1427
1581
  }
1428
1582
  async function syncSubscriptionByIdForJob(deps, subscriptionId, assertLease, candidatePriceId) {
1429
1583
  const subscription = await getSubscriptionById(deps, subscriptionId);
1430
- if (!subscription) {
1584
+ if (!subscription || isImportPending(subscription)) {
1431
1585
  return;
1432
1586
  }
1433
1587
  const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
@@ -1623,6 +1777,287 @@ function requireProvider(deps, providerName, capability) {
1623
1777
  }
1624
1778
  return provider;
1625
1779
  }
1780
+ function readImportRequest(input) {
1781
+ const invalid = (message) => createBillFnError({ code: 'BILLFN_VALIDATION_ERROR', message });
1782
+ const optionalReference = (value, name) => {
1783
+ if (value === undefined) {
1784
+ return undefined;
1785
+ }
1786
+ if (typeof value !== 'string' || value.trim().length === 0 || value !== value.trim()) {
1787
+ throw invalid(`${name} must be a non-empty string without surrounding whitespace`);
1788
+ }
1789
+ return value;
1790
+ };
1791
+ const providerSubscriptionId = optionalReference(input.providerSubscriptionId, 'providerSubscriptionId');
1792
+ const originalTransactionId = optionalReference(input.originalTransactionId, 'originalTransactionId');
1793
+ if (originalTransactionId !== undefined && input.provider !== 'apple') {
1794
+ throw invalid('originalTransactionId identifies Apple subscriptions only');
1795
+ }
1796
+ if (providerSubscriptionId !== undefined && originalTransactionId !== undefined && providerSubscriptionId !== originalTransactionId) {
1797
+ throw invalid('providerSubscriptionId and originalTransactionId name different subscriptions');
1798
+ }
1799
+ const reference = providerSubscriptionId ?? originalTransactionId;
1800
+ if (reference === undefined) {
1801
+ throw invalid('Importing a subscription requires providerSubscriptionId or originalTransactionId');
1802
+ }
1803
+ if (typeof input.billingAccountId !== 'string' || input.billingAccountId.length === 0) {
1804
+ throw invalid('billingAccountId must be a non-empty string');
1805
+ }
1806
+ if (typeof input.planKey !== 'string' || input.planKey.length === 0) {
1807
+ throw invalid('planKey must be a non-empty string');
1808
+ }
1809
+ const operator = input.operator;
1810
+ if (!operator || typeof operator.id !== 'string' || operator.id.length === 0) {
1811
+ throw invalid('Importing a subscription requires the operator identity');
1812
+ }
1813
+ if (input.reason !== undefined && typeof input.reason !== 'string') {
1814
+ throw invalid('reason must be a string');
1815
+ }
1816
+ return {
1817
+ provider: input.provider,
1818
+ reference,
1819
+ operator: {
1820
+ id: operator.id,
1821
+ ...(typeof operator.type === 'string' ? { type: operator.type } : {}),
1822
+ ...(typeof operator.requestId === 'string' ? { requestId: operator.requestId } : {})
1823
+ }
1824
+ };
1825
+ }
1826
+ /**
1827
+ * Reads the provider record to import. A BillFn error from the adapter keeps its classification; any other failure
1828
+ * (the provider was not reached, or the adapter failed without classifying it) is a retryable provider error whose
1829
+ * message names only the provider, so no raw error text reaches the operator.
1830
+ */
1831
+ async function readImportState(provider, providerName, input) {
1832
+ try {
1833
+ return await provider.importSubscription(input);
1834
+ }
1835
+ catch (error) {
1836
+ const code = error?.code;
1837
+ if (typeof code === 'string' && code.startsWith('BILLFN_')) {
1838
+ throw error;
1839
+ }
1840
+ throw createBillFnError({
1841
+ code: 'BILLFN_PROVIDER_ERROR',
1842
+ message: `Provider ${providerName} could not be read for the imported subscription`
1843
+ });
1844
+ }
1845
+ }
1846
+ /** The billing account an import binds to: created for the subject when one is given, otherwise existing. */
1847
+ async function resolveImportBillingAccount(deps, input) {
1848
+ if (input.subject) {
1849
+ const subject = requireSubject(input.subject);
1850
+ const resolved = await resolveBillingAccountReference(deps, subject);
1851
+ if (resolved.billingAccountId !== input.billingAccountId) {
1852
+ throw createBillFnError({
1853
+ code: 'BILLFN_VALIDATION_ERROR',
1854
+ message: 'The subject resolves to a different billing account'
1855
+ });
1856
+ }
1857
+ return (await getOrCreateBillingAccount(deps, subject)).billingAccount;
1858
+ }
1859
+ const billingAccount = await getBillingAccountById(deps, input.billingAccountId);
1860
+ if (!billingAccount) {
1861
+ throw createBillFnError({
1862
+ code: 'BILLFN_NOT_FOUND',
1863
+ message: 'Billing account not found'
1864
+ });
1865
+ }
1866
+ return billingAccount;
1867
+ }
1868
+ /**
1869
+ * The plan's subscription price the provider record's product names. Every reference (the adapter's
1870
+ * `providerProductId`, then the record's `product_id`/`productId`) is a provider product id here, so it is matched
1871
+ * only against `providerProductId`, never against a catalog `priceId` another plan's product could equal.
1872
+ */
1873
+ function requireImportPrice(plan, provider, state) {
1874
+ const raw = asRecord(state.raw);
1875
+ const records = [raw, asRecord(raw?.transaction)];
1876
+ const references = [
1877
+ state.providerProductId,
1878
+ ...records.flatMap((record) => (record ? ['product_id', 'productId'].map((key) => readString(record, key)) : []))
1879
+ ].filter((reference) => Boolean(reference));
1880
+ const prices = plan.prices.filter((price) => price.provider === provider && price.kind === 'subscription');
1881
+ for (const reference of references) {
1882
+ const price = prices.find((candidate) => candidate.providerProductId === reference);
1883
+ if (price) {
1884
+ return { plan, price };
1885
+ }
1886
+ }
1887
+ throw createBillFnError({
1888
+ code: 'BILLFN_VALIDATION_ERROR',
1889
+ message: `The provider subscription's product is not a ${provider} subscription price of plan ${plan.planKey}`,
1890
+ details: { planKey: plan.planKey, providerProductIds: references }
1891
+ });
1892
+ }
1893
+ function importBoundElsewhere(provider) {
1894
+ return createBillFnError({
1895
+ code: 'BILLFN_CONFLICT',
1896
+ message: `The ${provider} subscription is already bound to a different billing account`
1897
+ });
1898
+ }
1899
+ /** Whether a subscription of another billing account holds a reference the provider record names. */
1900
+ async function isBoundElsewhere(deps, provider, state, billingAccountId) {
1901
+ const holder = await findSubscriptionByProviderRef(deps, provider, state.providerSubscriptionId, state.providerChargeId);
1902
+ return Boolean(holder && holder.billingAccountId !== billingAccountId);
1903
+ }
1904
+ /**
1905
+ * The import is bound and recorded, but another run holds the reference's
1906
+ * unlinked events (including a run interrupted before it released them) or the
1907
+ * replay reached its bound. Unlike a bound-elsewhere conflict it is retryable:
1908
+ * calling import again, after the holder's lease expires, finishes the replay.
1909
+ */
1910
+ function importReplayUnfinished() {
1911
+ return createBillFnError({
1912
+ code: 'BILLFN_CONFLICT',
1913
+ message: 'The import is recorded but replaying its unlinked webhook events did not finish; import again to finish it',
1914
+ details: { reason: BILLFN_IMPORT_REPLAY_UNFINISHED_REASON },
1915
+ retryable: true
1916
+ });
1917
+ }
1918
+ function importReferenceReplaced() {
1919
+ return createBillFnError({
1920
+ code: 'BILLFN_CONFLICT',
1921
+ message: 'The provider reference was replaced by another subscription and cannot be imported'
1922
+ });
1923
+ }
1924
+ /**
1925
+ * Refuses an import of a reference a subscription write retired: deletes
1926
+ * `holder` when it is a pending import row holding the reference (whichever
1927
+ * import run created it), then fails with a non-retryable `BILLFN_CONFLICT`.
1928
+ * An accepted row is never deleted: the delete is ordered against a fresh
1929
+ * read that is still pending, so a stale `holder` another run has accepted
1930
+ * since survives.
1931
+ */
1932
+ async function refuseRetiredImportReference(deps, provider, reference, holder) {
1933
+ if (!(await isReplacedProviderReference(deps, provider, reference, holder?.id))) {
1934
+ return;
1935
+ }
1936
+ const stored = holder && isImportPending(holder) ? await getSubscriptionById(deps, holder.id) : null;
1937
+ if (stored && isImportPending(stored) && stored.providerSubscriptionId === reference) {
1938
+ await deps.db.deleteMany({
1939
+ model: TABLES.subscriptions,
1940
+ where: [
1941
+ { field: 'id', operator: 'eq', value: stored.id },
1942
+ { field: 'providerSubscriptionId', operator: 'eq', value: reference },
1943
+ { field: 'updatedAt', operator: 'eq', value: stored.updatedAt }
1944
+ ],
1945
+ namespace: deps.namespace
1946
+ });
1947
+ }
1948
+ deps.metrics.track('subscription.import.replaced_reference_refused', { provider });
1949
+ throw importReferenceReplaced();
1950
+ }
1951
+ /**
1952
+ * After an import's write to `subscription`, which holds `reference`: refuses
1953
+ * the binding when a write of another subscription retired the reference, or
1954
+ * accepts it by clearing the pending marker, and returns the accepted row:
1955
+ * `ownState` when that row carries this write's state, and `acceptedHere` when
1956
+ * this call cleared its pending marker. A lost acceptance write re-finds the
1957
+ * row, which then carries another run's state (accepted by it or here), so the
1958
+ * caller publishes that row's plan, never its own. Every row, the first and
1959
+ * each reload, is checked the same way before it is accepted or returned: it
1960
+ * holds the reference, belongs to `billingAccountId` (another account's row
1961
+ * fails as bound elsewhere, untouched) and its reference is not retired.
1962
+ * Nothing uses a pending row, so no entitlement or lifecycle event exists for
1963
+ * it until this accepts it. The import's audit event (`audit`) for the row it
1964
+ * accepts is recorded just before each acceptance write, so an accepted
1965
+ * binding always has it (an event for a write that then does not land stays);
1966
+ * a refusal deletes the row, and an interrupted run leaves it pending for the
1967
+ * rerun to settle.
1968
+ *
1969
+ * Why the check after the write is enough: a reference is held by at most one
1970
+ * row (unique index), so a writer that retired it took it off its row before
1971
+ * this write held it, and recorded it before that, or saved it pending in that
1972
+ * same write. Read pending, then recorded, now, one of the reads sees it. Every
1973
+ * import that holds the row therefore reaches the same answer.
1974
+ */
1975
+ async function settleImportBinding(deps, subscription, reference, billingAccountId, audit) {
1976
+ let current = subscription;
1977
+ for (let attempt = 0; attempt < 50; attempt += 1) {
1978
+ // Only the first attempt holds this run's write; a reload holds whatever wrote the row after it.
1979
+ const ownState = attempt === 0;
1980
+ if (current?.providerSubscriptionId !== reference) {
1981
+ throw createBillFnError({
1982
+ code: 'BILLFN_CONFLICT',
1983
+ message: 'The imported subscription changed while its import was accepted',
1984
+ retryable: true
1985
+ });
1986
+ }
1987
+ if (current.billingAccountId !== billingAccountId) {
1988
+ throw importBoundElsewhere(current.provider);
1989
+ }
1990
+ await refuseRetiredImportReference(deps, current.provider, reference, current);
1991
+ if (!isImportPending(current)) {
1992
+ return { subscription: current, ownState, acceptedHere: false };
1993
+ }
1994
+ const accepted = {
1995
+ ...current,
1996
+ metadata: withoutImportPending(current.metadata),
1997
+ updatedAt: nextSubscriptionUpdatedAt(deps.now(), current.updatedAt)
1998
+ };
1999
+ if (await updateSubscriptionAfterImportAudit(deps, current, accepted, audit)) {
2000
+ return { subscription: accepted, ownState, acceptedHere: true };
2001
+ }
2002
+ current = await findSubscriptionByProviderRef(deps, subscription.provider, reference);
2003
+ await Promise.resolve();
2004
+ }
2005
+ throw createBillFnError({
2006
+ code: 'BILLFN_CONFLICT',
2007
+ message: 'Unable to accept the imported subscription after multiple retries',
2008
+ retryable: true
2009
+ });
2010
+ }
2011
+ /** Whether the row is an import's binding not yet accepted (see `BILLFN_IMPORT_PENDING_METADATA_KEY`). */
2012
+ function isImportPending(subscription) {
2013
+ return subscription?.metadata?.[BILLFN_IMPORT_PENDING_METADATA_KEY] === true;
2014
+ }
2015
+ function withoutImportPending(metadata) {
2016
+ if (!metadata) {
2017
+ return metadata;
2018
+ }
2019
+ const { [BILLFN_IMPORT_PENDING_METADATA_KEY]: _pending, ...rest } = metadata;
2020
+ return rest;
2021
+ }
2022
+ /** A write other than an import's reached a pending import row: retried once the import settles it. */
2023
+ function importPendingConflict() {
2024
+ return createBillFnError({
2025
+ code: 'BILLFN_CONFLICT',
2026
+ message: 'The subscription is being imported',
2027
+ details: { reason: 'subscription_import_pending' },
2028
+ retryable: true
2029
+ });
2030
+ }
2031
+ /**
2032
+ * Whether a subscription write dropped this reference (still pending on the
2033
+ * subscription, or recorded). A writer records the reference before it clears
2034
+ * pending, so pending is read first, as `namesReplacedReference` does: a writer
2035
+ * that clears pending between the reads has already recorded it. `holderId`,
2036
+ * the row that holds the reference now, is not counted: a reference it dropped
2037
+ * and holds again, or one a write that lost its race on it recorded, is held.
2038
+ */
2039
+ async function isReplacedProviderReference(deps, provider, reference, holderId) {
2040
+ const pending = await deps.db.findMany({
2041
+ model: TABLES.subscriptions,
2042
+ where: [
2043
+ { field: 'provider', operator: 'eq', value: provider },
2044
+ { field: 'replacedReferencesPending', operator: 'eq', value: true }
2045
+ ],
2046
+ namespace: deps.namespace
2047
+ });
2048
+ if (pending.some((subscription) => subscription.id !== holderId && pendingReplacedReferencesOf(subscription).includes(reference))) {
2049
+ return true;
2050
+ }
2051
+ const replaced = await deps.db.findOne({
2052
+ model: TABLES.replacedProviderReferences,
2053
+ where: [
2054
+ { field: 'provider', operator: 'eq', value: provider },
2055
+ { field: 'providerReference', operator: 'eq', value: reference }
2056
+ ],
2057
+ namespace: deps.namespace
2058
+ });
2059
+ return Boolean(replaced && replaced.subscriptionId !== holderId);
2060
+ }
1626
2061
  function requireSubject(subject) {
1627
2062
  if (!subject) {
1628
2063
  throw createBillFnError({
@@ -1678,7 +2113,7 @@ async function getSubscriptionById(deps, id) {
1678
2113
  }
1679
2114
  async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId) {
1680
2115
  if (subscriptionId) {
1681
- return deps.db.findOne({
2116
+ const subscription = await deps.db.findOne({
1682
2117
  model: TABLES.subscriptions,
1683
2118
  where: [
1684
2119
  { field: 'id', operator: 'eq', value: subscriptionId },
@@ -1686,6 +2121,7 @@ async function getSubscriptionForAccount(deps, billingAccountId, subscriptionId)
1686
2121
  ],
1687
2122
  namespace: deps.namespace
1688
2123
  });
2124
+ return isImportPending(subscription) ? null : subscription;
1689
2125
  }
1690
2126
  return findActiveSubscription(deps, billingAccountId);
1691
2127
  }
@@ -1699,7 +2135,8 @@ async function findActiveSubscription(deps, billingAccountId) {
1699
2135
  orderBy: [{ field: 'updatedAt', direction: 'desc' }],
1700
2136
  namespace: deps.namespace
1701
2137
  });
1702
- return candidates[0] ?? null;
2138
+ // A pending import row is not the account's until its import accepts it.
2139
+ return candidates.find((candidate) => !isImportPending(candidate)) ?? null;
1703
2140
  }
1704
2141
  async function findSubscriptionByProviderRef(deps, provider, providerSubscriptionId, providerChargeId, checkoutReferenceId) {
1705
2142
  const whereSets = [];
@@ -1999,12 +2436,23 @@ async function applyVerificationState(deps, input) {
1999
2436
  // A read is superseded by an event newer than its start; a committed mutation only by one after its commit.
2000
2437
  const supersededBefore = input.committedAt ?? input.liveStateObservedAt;
2001
2438
  for (let attempt = 0; attempt < 50; attempt += 1) {
2439
+ if (!input.importReference && isImportPending(baseSubscription)) {
2440
+ // Only the import settles its pending row; anything else applies once it is accepted (or deleted).
2441
+ throw importPendingConflict();
2442
+ }
2002
2443
  if (baseSubscription && baseSubscription.billingAccountId !== input.billingAccount.id) {
2003
2444
  throw createBillFnError({
2004
2445
  code: 'BILLFN_CONFLICT',
2005
2446
  message: 'Provider billing reference belongs to a different billing account'
2006
2447
  });
2007
2448
  }
2449
+ if (input.importReference && baseSubscription && baseSubscription.providerSubscriptionId !== input.importReference) {
2450
+ // Found by another reference the provider record names: an import never moves its reference onto such a row.
2451
+ throw createBillFnError({
2452
+ code: 'BILLFN_CONFLICT',
2453
+ message: 'The provider record names a reference another subscription holds'
2454
+ });
2455
+ }
2008
2456
  const order = baseSubscription && input.occurredAt ? orderProviderEvent(baseSubscription, input.occurredAt) : 'apply';
2009
2457
  if (baseSubscription && order === 'skip') {
2010
2458
  // The older event's state is skipped, but a reference it adds still belongs to this subscription.
@@ -2037,21 +2485,13 @@ async function applyVerificationState(deps, input) {
2037
2485
  subscription = baseSubscription;
2038
2486
  break;
2039
2487
  }
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) {
2488
+ // An import that adds its marker or references to an accepted row records its audit first.
2489
+ if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, linked, input.importAudit)) {
2050
2490
  subscription = linked;
2051
2491
  break;
2052
2492
  }
2053
- const current = await getSubscriptionById(deps, baseSubscription.id);
2054
- if (!current) {
2493
+ const current = await reloadAfterLostWrite(deps, input, baseSubscription);
2494
+ if (!current && !input.importReference) {
2055
2495
  throw createBillFnError({
2056
2496
  code: 'BILLFN_CONFLICT',
2057
2497
  message: 'Subscription changed while applying provider state'
@@ -2062,16 +2502,20 @@ async function applyVerificationState(deps, input) {
2062
2502
  continue;
2063
2503
  }
2064
2504
  // 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);
2505
+ const merged = mergeJson(mergeJson(input.metadataDefaults, baseSubscription?.metadata), input.state.raw);
2066
2506
  const mergedMetadata = input.keepMutationWindow ? merged : withoutMutationWindow(merged);
2067
2507
  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;
2508
+ const metadata = {
2509
+ ...mergedMetadata,
2510
+ ...(providerStateAt
2511
+ ? {
2512
+ [PROVIDER_EVENT_OCCURRED_AT_METADATA_KEY]: providerStateAt,
2513
+ ...(input.committedAt ? { [PROVIDER_MUTATION_STARTED_AT_METADATA_KEY]: input.liveStateObservedAt } : {})
2514
+ }
2515
+ : {}),
2516
+ // A row an import creates stays pending until `settleImportBinding` accepts it.
2517
+ ...(input.importReference && !baseSubscription ? { [BILLFN_IMPORT_PENDING_METADATA_KEY]: true } : {})
2518
+ };
2075
2519
  const subscriptionId = baseSubscription?.id ?? deps.generateId('sub');
2076
2520
  const record = {
2077
2521
  id: subscriptionId,
@@ -2101,23 +2545,15 @@ async function applyVerificationState(deps, input) {
2101
2545
  const pending = await recordDroppedReferences(deps, baseSubscription, record, Boolean(input.committedAt));
2102
2546
  Object.assign(record, withPendingReplacements(record.metadata, pending));
2103
2547
  // 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) {
2548
+ // pending references it clears always come from the row it replaces. A re-import of an accepted row
2549
+ // records its audit first.
2550
+ if (await updateSubscriptionAfterImportAudit(deps, baseSubscription, record, input.importAudit)) {
2115
2551
  subscription = record;
2116
2552
  break;
2117
2553
  }
2118
2554
  // 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) {
2555
+ const current = await reloadAfterLostWrite(deps, input, baseSubscription);
2556
+ if (!current && !input.importReference) {
2121
2557
  throw createBillFnError({
2122
2558
  code: 'BILLFN_CONFLICT',
2123
2559
  message: 'Subscription changed while applying provider event state'
@@ -2153,6 +2589,16 @@ async function applyVerificationState(deps, input) {
2153
2589
  message: 'Unable to apply provider event state after multiple retries'
2154
2590
  });
2155
2591
  }
2592
+ // Whether the row carries this call's state; otherwise it publishes the row's own plan, never `input.plan`.
2593
+ let ownState = !liveStateSuperseded;
2594
+ // Whether this call records the row's lifecycle event (an import that accepts another run's state records it).
2595
+ let recordLifecycle = ownState;
2596
+ if (input.importReference) {
2597
+ const settled = await settleImportBinding(deps, subscription, input.importReference, input.billingAccount.id, input.importAudit);
2598
+ subscription = settled.subscription;
2599
+ ownState = ownState && settled.ownState;
2600
+ recordLifecycle = ownState || settled.acceptedHere;
2601
+ }
2156
2602
  const checkoutSession = input.checkoutSession
2157
2603
  ? await updateCheckoutSession(deps, input.checkoutSession.checkoutSessionId, {
2158
2604
  status: input.state.checkoutStatus,
@@ -2162,9 +2608,10 @@ async function applyVerificationState(deps, input) {
2162
2608
  metadata: mergeJson(input.checkoutSession.metadata, input.state.raw)
2163
2609
  })
2164
2610
  : 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);
2611
+ // A superseded read leaves the subscription's state, so its entitlements stand and no lifecycle event is recorded;
2612
+ // an import that ends on another run's state publishes that state's plan.
2613
+ const entitlements = await upsertEntitlements(deps, input.billingAccount.id, ownState ? input.plan : requirePlan(deps.catalog, subscription.planKey), subscription, timestamp);
2614
+ if (!recordLifecycle) {
2168
2615
  return {
2169
2616
  checkoutSession: checkoutSession ?? input.checkoutSession,
2170
2617
  subscription,
@@ -2172,7 +2619,6 @@ async function applyVerificationState(deps, input) {
2172
2619
  providerStateRaced
2173
2620
  };
2174
2621
  }
2175
- const entitlements = await upsertEntitlements(deps, input.billingAccount.id, input.plan, subscription, timestamp);
2176
2622
  await recordBillingEvent(deps, {
2177
2623
  // A provider event keys its lifecycle entry, so applying it again (a reclaimed replay) records it once.
2178
2624
  id: input.providerEventId
@@ -2198,6 +2644,17 @@ async function applyVerificationState(deps, input) {
2198
2644
  providerStateRaced
2199
2645
  };
2200
2646
  }
2647
+ /**
2648
+ * The row to retry a lost ordered write against: the row again, or for an
2649
+ * import the row that holds its reference now (null when none does, and the
2650
+ * import creates one), so the import never writes its reference back onto a
2651
+ * row a writer took it from.
2652
+ */
2653
+ function reloadAfterLostWrite(deps, input, subscription) {
2654
+ return input.importReference
2655
+ ? findSubscriptionByProviderRef(deps, input.price.provider, input.importReference)
2656
+ : getSubscriptionById(deps, subscription.id);
2657
+ }
2201
2658
  /**
2202
2659
  * The provider-state time a write saves: the event's time, or for live state
2203
2660
  * the read start (a committed mutation: its commit), never moving the stored
@@ -2209,19 +2666,22 @@ function providerStateTimeFor(occurredAt, liveStateAt, subscription) {
2209
2666
  }
2210
2667
  /**
2211
2668
  * 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.
2669
+ * superseded: missing provider references and metadata keys from the read are
2670
+ * added and every state field is kept. Returns null when the read adds neither.
2214
2671
  */
2215
2672
  function linkLiveReadReferences(subscription, input, timestamp) {
2216
2673
  const providerSubscriptionId = subscription.providerSubscriptionId ?? input.state.providerSubscriptionId;
2217
2674
  const providerCheckoutId = subscription.providerCheckoutId ?? input.checkoutSession?.providerCheckoutId;
2218
2675
  const providerChargeId = subscription.providerChargeId ?? input.state.providerChargeId;
2676
+ const addsMetadata = Object.keys(input.metadataDefaults ?? {}).some((key) => subscription.metadata?.[key] === undefined);
2219
2677
  if (providerSubscriptionId === subscription.providerSubscriptionId &&
2220
2678
  providerCheckoutId === subscription.providerCheckoutId &&
2221
- providerChargeId === subscription.providerChargeId) {
2679
+ providerChargeId === subscription.providerChargeId &&
2680
+ !addsMetadata) {
2222
2681
  return null;
2223
2682
  }
2224
- return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, updatedAt: timestamp };
2683
+ const metadata = addsMetadata ? { ...input.metadataDefaults, ...subscription.metadata } : subscription.metadata;
2684
+ return { ...subscription, providerSubscriptionId, providerCheckoutId, providerChargeId, metadata, updatedAt: timestamp };
2225
2685
  }
2226
2686
  /**
2227
2687
  * Links the provider references of an event skipped as older than the
@@ -2274,6 +2734,28 @@ async function linkableMissingReferences(deps, subscription, state) {
2274
2734
  }
2275
2735
  return data;
2276
2736
  }
2737
+ /**
2738
+ * The ordered update of an existing row that `applyVerificationState` (state and superseded-read link updates)
2739
+ * and an import's acceptance (`settleImportBinding`) go through. With an import's audit, a write that leaves the
2740
+ * row usable (not pending) records the run's audit for the state it publishes first, so no later failure or
2741
+ * interruption leaves an import's change without its operator. The row an import creates is pending, so it is
2742
+ * audited when this accepts it.
2743
+ */
2744
+ async function updateSubscriptionAfterImportAudit(deps, current, next, importAudit) {
2745
+ if (importAudit && !isImportPending(next)) {
2746
+ await recordBillingEvent(deps, importAudit(next));
2747
+ }
2748
+ const updated = await deps.db.updateMany({
2749
+ model: TABLES.subscriptions,
2750
+ where: [
2751
+ { field: 'id', operator: 'eq', value: current.id },
2752
+ { field: 'updatedAt', operator: 'eq', value: current.updatedAt }
2753
+ ],
2754
+ data: next,
2755
+ namespace: deps.namespace
2756
+ });
2757
+ return updated === 1;
2758
+ }
2277
2759
  /** Writes a subscription row ordered against the row it was computed from; false when that row changed. */
2278
2760
  async function writeOrderedSubscription(deps, current, next) {
2279
2761
  try {
@@ -2376,7 +2858,8 @@ async function recordBillingEvent(deps, event) {
2376
2858
  });
2377
2859
  }
2378
2860
  catch (error) {
2379
- // A provider-event-keyed entry already recorded by an earlier apply of the same event.
2861
+ // Already recorded: a provider-event-keyed entry by an earlier apply of the same event, or a digest-keyed
2862
+ // import event by an earlier write of the same import run and state. The first write wins.
2380
2863
  if (!isDuplicateRecordError(error)) {
2381
2864
  throw error;
2382
2865
  }
@@ -2536,6 +3019,10 @@ async function appendUsageLedgerEntry(deps, billingAccountId, resource, amount)
2536
3019
  }
2537
3020
  async function resolveProjectionContextForProviderState(deps, input) {
2538
3021
  const subscription = await findSubscriptionByProviderRef(deps, input.provider, input.state.providerSubscriptionId, input.state.providerChargeId, input.checkoutReferenceId);
3022
+ if (isImportPending(subscription)) {
3023
+ // Not usable until its import accepts it: the event is stored unlinked and replayed then.
3024
+ return null;
3025
+ }
2539
3026
  if (subscription) {
2540
3027
  const billingAccount = await getBillingAccountById(deps, subscription.billingAccountId);
2541
3028
  if (!billingAccount) {
@@ -2694,7 +3181,12 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
2694
3181
  const metadataBillingAccountId = readBillingAccountMetadata(event, state);
2695
3182
  const existing = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
2696
3183
  let reason;
2697
- if (existing) {
3184
+ const importPending = isImportPending(existing);
3185
+ if (importPending) {
3186
+ // Its import replays this event once it accepts the row; a refused import leaves it unlinked.
3187
+ reason = 'subscription_import_pending';
3188
+ }
3189
+ else if (existing) {
2698
3190
  // A link that committed after this event's first lookup is applied to like any linked reference.
2699
3191
  const projection = await resolveProjectionContextForProviderState(deps, {
2700
3192
  provider: providerName,
@@ -2726,10 +3218,11 @@ async function bootstrapOrStoreUnlinkedWebhookEvent(deps, providerName, event, s
2726
3218
  reason,
2727
3219
  metadataBillingAccountId
2728
3220
  });
2729
- if (existing) {
3221
+ if (existing && !importPending) {
2730
3222
  return { handled: null };
2731
3223
  }
2732
- // A link that committed while this event was being stored would have missed it; replay it now.
3224
+ // A link (or an import's acceptance) that committed while this event was being stored would have missed it;
3225
+ // replay it now. The replay skips a row still pending.
2733
3226
  const linked = await findSubscriptionByProviderRef(deps, providerName, state.providerSubscriptionId, state.providerChargeId, event.checkoutReferenceId);
2734
3227
  if (linked) {
2735
3228
  await replayUnlinkedWebhookEventsOrThrow(deps, linked);
@@ -2878,6 +3371,10 @@ async function purgeExpiredUnlinkedWebhookEvents(deps, timestamp) {
2878
3371
  */
2879
3372
  async function replayUnlinkedWebhookEvents(deps, subscription) {
2880
3373
  const result = { applied: 0, skipped: 0, pending: 0 };
3374
+ if (isImportPending(subscription)) {
3375
+ // A pending import row is not linked yet: its import replays the events once it accepts the row.
3376
+ return result;
3377
+ }
2881
3378
  const queue = createUnlinkedReplayQueue(deps, subscription.provider);
2882
3379
  let latest = subscription;
2883
3380
  await queue.discover(subscription);